Compare commits
144 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 422f18443a | |||
| 0fbf185223 | |||
| 91cdffa9a6 | |||
| 89199eed8b | |||
| 1a65bb9db6 | |||
| 9772d9ca62 | |||
| 5ee93a5db1 | |||
| 85c4f686da | |||
| 1f066b0d1e | |||
| 38ddb7aaa3 | |||
| a22931f96b | |||
| dcee067aea | |||
| 302d118ae0 | |||
| 09eea96cdc | |||
| 205dea9e58 | |||
| 089eabb806 | |||
| dd63500202 | |||
| 13488efdaf | |||
| edcf33f49c | |||
| d601e01029 | |||
| 4d7c3650b5 | |||
| a0fd468309 | |||
| 0b7471879d | |||
| 282b74c95e | |||
| 5ce1dc973c | |||
| 7ac1e0b69f | |||
| 127bed1026 | |||
| d517a1862b | |||
| 8e5202b375 | |||
| 301fb58917 | |||
| aedebb1461 | |||
| 1336377598 | |||
| 3b58dcbc4d | |||
| 23a5f0a96f | |||
| b81d2ec63c | |||
| cabe365907 | |||
| 54f61ecb18 | |||
| 404cd62d7b | |||
| f0f3143605 | |||
| b729fdedca | |||
| 973cec2c6a | |||
| 6e61aba050 | |||
| 40a8deba12 | |||
| 0ba762ba6e | |||
| 732c256db2 | |||
| d2ce801277 | |||
| 4845fdedfd | |||
| f5a7e2e2d6 | |||
| a84f48ae8a | |||
| c4c9503e31 | |||
| 8c1f499ed8 | |||
| c6eb3cfb74 | |||
| d7a0d253c4 | |||
| 9d275a3051 | |||
| 8192a48bc5 | |||
| 247181830c | |||
| d5dfdc8c05 | |||
| d95fcd8b00 | |||
| 40a366e68d | |||
| 593ae10783 | |||
| 75d9e11bab | |||
| 6d37d42e50 | |||
| f9e9c9285f | |||
| 314bf876f2 | |||
| 61c52cc888 | |||
| b42f226aba | |||
| 767b841866 | |||
| fd08666f49 | |||
| eb662681a1 | |||
| ef15eccc33 | |||
| e0286ff133 | |||
| 2baffe3f11 | |||
| 196657ee86 | |||
| b4c4aafc99 | |||
| b907a79223 | |||
| 0d213de580 | |||
| 2e97d699e7 | |||
| 22e4738b7a | |||
| 4b68641043 | |||
| 3a66a3d655 | |||
| 76d1d3aef3 | |||
| fe2aa5d28b | |||
| f474bbf09a | |||
| c4d02db879 | |||
| 75b36ec547 | |||
| 43b7aea76e | |||
| 13a4a47d40 | |||
| fd9f380922 | |||
| a5fdb9d6b7 | |||
| 983c65e4f8 | |||
| fa568056d3 | |||
| ea8b2812d4 | |||
| b6a2aea8fd | |||
| fa047bf303 | |||
| 4177ac3d46 | |||
| 7647f4f5b9 | |||
| bab03dbde1 | |||
| 85db6e96af | |||
| 548461ade0 | |||
| ecbf182173 | |||
| ab38d33e31 | |||
| 0020f4ede9 | |||
| b470cb63b7 | |||
| 32f2eaef3c | |||
| a7fc3c78eb | |||
| d42b3ecb5e | |||
| 2d4a603d11 | |||
| 7a369328b6 | |||
| 545667e502 | |||
| 8387a81a59 | |||
| 1e3087ccf0 | |||
| bee7772bfd | |||
| a8b6f72ee6 | |||
| 9484cf514d | |||
| 70958c00d3 | |||
| 9467335536 | |||
| f916fdff06 | |||
| ced346506e | |||
| e0cd96f87e | |||
| ed7e906903 | |||
| 3bb7f5abf4 | |||
| 5d801a2343 | |||
| 0d9e59dad9 | |||
| 5fd1396b5c | |||
| b3b9d9293b | |||
| 8b405e513f | |||
| 2bfcfbf03d | |||
| 10388a5ffa | |||
| a127a381cc | |||
| 4aa0dc20af | |||
| 012cc04107 | |||
| 25a84ad3af | |||
| c0036d230a | |||
| 869970a4ab | |||
| 42f8aa410c | |||
| 80e1223505 | |||
| fc9581580c | |||
| b0e6764bfe | |||
| 03fef81414 | |||
| 8dcb8648a5 | |||
| 50b4c96a99 | |||
| c9b894fdbe | |||
| a2f1d944ad | |||
| be4ec2bcce |
@@ -3,13 +3,13 @@ name: Build backend file for windows
|
||||
inputs:
|
||||
go-version:
|
||||
required: false
|
||||
default: "1.25.5"
|
||||
default: "1.25.7"
|
||||
mingw-version:
|
||||
required: false
|
||||
default: "15.2.0"
|
||||
mingw-revison:
|
||||
required: false
|
||||
default: "v13-rev0"
|
||||
default: "v13-rev1"
|
||||
release-build:
|
||||
required: false
|
||||
type: string
|
||||
|
||||
+14
@@ -147,3 +147,17 @@ dist/
|
||||
|
||||
# Roo Code
|
||||
.roo/
|
||||
|
||||
# Binary and build files
|
||||
ezbookkeeping
|
||||
!**/ezbookkeeping/
|
||||
package/
|
||||
|
||||
# Environment variable files
|
||||
.env
|
||||
**/.env
|
||||
|
||||
# Other directories
|
||||
data/
|
||||
storage/
|
||||
log/
|
||||
|
||||
+3
-3
@@ -1,5 +1,5 @@
|
||||
# Build backend binary file
|
||||
FROM golang:1.25.5-alpine3.23 AS be-builder
|
||||
FROM golang:1.25.7-alpine3.23 AS be-builder
|
||||
ARG RELEASE_BUILD
|
||||
ARG BUILD_PIPELINE
|
||||
ARG BUILD_UNIXTIME
|
||||
@@ -19,7 +19,7 @@ RUN apk add git gcc g++ libc-dev
|
||||
RUN ./build.sh backend
|
||||
|
||||
# Build frontend files
|
||||
FROM --platform=$BUILDPLATFORM node:24.12.0-alpine3.23 AS fe-builder
|
||||
FROM --platform=$BUILDPLATFORM node:24.14.0-alpine3.23 AS fe-builder
|
||||
ARG RELEASE_BUILD
|
||||
ARG BUILD_PIPELINE
|
||||
ARG BUILD_UNIXTIME
|
||||
@@ -35,7 +35,7 @@ RUN apk add git
|
||||
RUN ./build.sh frontend
|
||||
|
||||
# Package docker image
|
||||
FROM alpine:3.23.2
|
||||
FROM alpine:3.23.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
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
[](https://trendshift.io/repositories/12917)
|
||||
|
||||
## Introduction
|
||||
ezBookkeeping is a lightweight, self-hosted personal finance app with a user-friendly interface and powerful bookkeeping features. It's easy to deploy, and you can start it with just one single Docker command. Designed to be resource-efficient and highly scalable, it can run smoothly on devices as small as a Raspberry Pi, or scale up to NAS, MicroServers, and even large cluster environments.
|
||||
ezBookkeeping is a lightweight, self-hosted personal finance app with a user-friendly interface and powerful bookkeeping features. It helps you record daily transactions, import data from various sources, and quickly search and filter your bills. You can analyze historical data using built-in charts or perform custom queries with your own chart dimensions to better understand spending patterns and financial trends. ezBookkeeping is easy to deploy, and you can start it with just one single Docker command. Designed to be resource-efficient, it runs smoothly on devices such as Raspberry Pi, NAS, and MicroServers.
|
||||
|
||||
ezBookkeeping offers tailored interfaces for both mobile and desktop devices. With support for PWA (Progressive Web Apps), you can even [add it to your mobile home screen](https://raw.githubusercontent.com/wiki/mayswind/ezbookkeeping/img/mobile/add_to_home_screen.gif) and use it like a native app.
|
||||
|
||||
@@ -21,9 +21,9 @@ Live Demo: [https://ezbookkeeping-demo.mayswind.net](https://ezbookkeeping-demo.
|
||||
- **Open Source & Self-Hosted**
|
||||
- Built for privacy and control
|
||||
- **Lightweight & Fast**
|
||||
- Optimized for performance, runs smoothly even on low-resource environments
|
||||
- Minimal resource usage, runs smoothly even on low-resource devices
|
||||
- **Easy Installation**
|
||||
- Docker-ready
|
||||
- Docker support
|
||||
- Supports SQLite, MySQL, PostgreSQL
|
||||
- Cross-platform (Windows, macOS, Linux)
|
||||
- Works on x86, amd64, ARM architectures
|
||||
@@ -33,24 +33,28 @@ Live Demo: [https://ezbookkeeping-demo.mayswind.net](https://ezbookkeeping-demo.
|
||||
- Dark mode
|
||||
- **AI-Powered Features**
|
||||
- Receipt image recognition
|
||||
- Supports MCP (Model Context Protocol) for AI integration
|
||||
- MCP (Model Context Protocol) support for AI integration
|
||||
- API command-line script tools for AI integration
|
||||
- **Powerful Bookkeeping**
|
||||
- Two-level accounts and categories
|
||||
- Attach images to transactions
|
||||
- Image attachments for transactions
|
||||
- Location tracking with maps
|
||||
- Recurring transactions
|
||||
- Advanced filtering, search, visualization, and analysis
|
||||
- **Localization & Globalization**
|
||||
- Scheduled transactions
|
||||
- Advanced filtering, search, visualization and analysis
|
||||
- **Localization & Internationalization**
|
||||
- Multi-language and multi-currency support
|
||||
- Automatic exchange rates
|
||||
- Multi-timezone awareness
|
||||
- Custom formats for dates, numbers, and currencies
|
||||
- Multiple exchange rate sources with automatic updates
|
||||
- Multi-timezone support
|
||||
- Custom formats for dates, numbers and currencies
|
||||
- **Security**
|
||||
- Two-factor authentication (2FA)
|
||||
- OIDC external authentication
|
||||
- Login rate limiting
|
||||
- Application lock (PIN code / WebAuthn)
|
||||
- **Data Import/Export**
|
||||
- Supports CSV, OFX, QFX, QIF, IIF, Camt.053, MT940, GnuCash, Firefly III, Beancount, and more
|
||||
- **Data Import & Export**
|
||||
- Supports CSV, OFX, QFX, QIF, IIF, Camt.052, Camt.053, MT940, GnuCash, Firefly III, Beancount and more
|
||||
|
||||
For a full list of features, visit the [Full Feature List](https://ezbookkeeping.mayswind.net/comparison/).
|
||||
|
||||
## Screenshots
|
||||
### Desktop Version
|
||||
@@ -112,16 +116,16 @@ You can also build a Docker image. Make sure you have [Docker](https://www.docke
|
||||
## Contributing
|
||||
We welcome contributions of all kinds.
|
||||
|
||||
Found a bug? [Submit an issue](https://github.com/mayswind/ezbookkeeping/issues)
|
||||
If you find a bug, please [submit an issue](https://github.com/mayswind/ezbookkeeping/issues) on GitHub.
|
||||
|
||||
Want to contribute code? Feel free to fork and send a pull request.
|
||||
If you would like to contribute code, you can fork the repository and open a pull request.
|
||||
|
||||
Contributions of all kinds — bug reports, feature suggestions, documentation improvements, or code — are highly appreciated.
|
||||
Improvements to documentation, feature suggestions, and other forms of feedback are also appreciated.
|
||||
|
||||
Check out our [Contributor Graph](https://github.com/mayswind/ezbookkeeping/graphs/contributors) to see the amazing people who've already helped.
|
||||
You can view existing contributors on the [Contributor Graph](https://github.com/mayswind/ezbookkeeping/graphs/contributors).
|
||||
|
||||
## Translating
|
||||
Help make ezBookkeeping accessible to users around the world. If you want to contribute a translation, please refer to our [translation guide](https://ezbookkeeping.mayswind.net/translating).
|
||||
Help make ezBookkeeping accessible to users around the world. We welcome help to improve existing translations or add new ones. If you would like to contribute a translation, please refer to the [translation guide](https://ezbookkeeping.mayswind.net/translating).
|
||||
|
||||
Currently available translations:
|
||||
|
||||
@@ -129,16 +133,17 @@ Currently available translations:
|
||||
| --- | --- | --- |
|
||||
| de | Deutsch | [@chrgm](https://github.com/chrgm) |
|
||||
| en | English | / |
|
||||
| es | Español | [@Miguelonlonlon](https://github.com/Miguelonlonlon), [@abrugues](https://github.com/abrugues) |
|
||||
| es | Español | [@Miguelonlonlon](https://github.com/Miguelonlonlon), [@abrugues](https://github.com/abrugues), [@AndresTeller](https://github.com/AndresTeller), [@diegofercri](https://github.com/diegofercri) |
|
||||
| fr | Français | [@brieucdlf](https://github.com/brieucdlf) |
|
||||
| it | Italiano | [@waron97](https://github.com/waron97) |
|
||||
| ja | 日本語 | [@tkymmm](https://github.com/tkymmm) |
|
||||
| kn | ಕನ್ನಡ | [@Darshanbm05](https://github.com/Darshanbm05) |
|
||||
| ko | 한국어 | [@overworks](https://github.com/overworks) |
|
||||
| nl | Nederlands | [@automagics](https://github.com/automagics) |
|
||||
| pt-BR | Português (Brasil) | [@thecodergus](https://github.com/thecodergus) |
|
||||
| ru | Русский | [@artegoser](https://github.com/artegoser) |
|
||||
| pt-BR | Português (Brasil) | [@thecodergus](https://github.com/thecodergus), [@balaios](https://github.com/balaios) |
|
||||
| ru | Русский | [@artegoser](https://github.com/artegoser), [@dshemin](https://github.com/dshemin) |
|
||||
| sl | Slovenščina | [@thehijacker](https://github.com/thehijacker) |
|
||||
| ta | தமிழ் | [@hhharsha36](https://github.com/hhharsha36) |
|
||||
| th | ไทย | [@natthavat28](https://github.com/natthavat28) |
|
||||
| tr | Türkçe | [@aydnykn](https://github.com/aydnykn) |
|
||||
| uk | Українська | [@nktlitvinenko](https://github.com/nktlitvinenko) |
|
||||
@@ -146,8 +151,6 @@ Currently available translations:
|
||||
| zh-Hans | 中文 (简体) | / |
|
||||
| zh-Hant | 中文 (繁體) | / |
|
||||
|
||||
Don't see your language? Help us add it.
|
||||
|
||||
## Documentation
|
||||
1. [English](https://ezbookkeeping.mayswind.net)
|
||||
1. [中文 (简体)](https://ezbookkeeping.mayswind.net/zh_Hans)
|
||||
|
||||
@@ -195,9 +195,25 @@ func getConfigWithoutSensitiveData(config *settings.Config) *settings.Config {
|
||||
clonedConfig.ReceiptImageRecognitionLLMConfig.OpenAICompatibleAPIKey = "****"
|
||||
}
|
||||
|
||||
if clonedConfig.ReceiptImageRecognitionLLMConfig.AnthropicCompatibleAPIKey != "" {
|
||||
clonedConfig.ReceiptImageRecognitionLLMConfig.AnthropicCompatibleAPIKey = "****"
|
||||
}
|
||||
|
||||
if clonedConfig.ReceiptImageRecognitionLLMConfig.AnthropicAPIKey != "" {
|
||||
clonedConfig.ReceiptImageRecognitionLLMConfig.AnthropicAPIKey = "****"
|
||||
}
|
||||
|
||||
if clonedConfig.ReceiptImageRecognitionLLMConfig.OpenRouterAPIKey != "" {
|
||||
clonedConfig.ReceiptImageRecognitionLLMConfig.OpenRouterAPIKey = "****"
|
||||
}
|
||||
|
||||
if clonedConfig.ReceiptImageRecognitionLLMConfig.LMStudioToken != "" {
|
||||
clonedConfig.ReceiptImageRecognitionLLMConfig.LMStudioToken = "****"
|
||||
}
|
||||
|
||||
if clonedConfig.ReceiptImageRecognitionLLMConfig.GoogleAIAPIKey != "" {
|
||||
clonedConfig.ReceiptImageRecognitionLLMConfig.GoogleAIAPIKey = "****"
|
||||
}
|
||||
}
|
||||
|
||||
if clonedConfig.OAuth2ClientSecret != "" {
|
||||
|
||||
+2
-1
@@ -316,6 +316,7 @@ func startWebServer(c *core.CliContext) error {
|
||||
|
||||
apiV1Route := apiRoute.Group("/v1")
|
||||
apiV1Route.Use(bindMiddleware(middlewares.JWTAuthorization(config)))
|
||||
apiV1Route.Use(bindMiddleware(middlewares.APITokenIpLimit(config)))
|
||||
{
|
||||
// Tokens
|
||||
apiV1Route.GET("/tokens/list.json", bindApi(api.Tokens.TokenListHandler))
|
||||
@@ -396,7 +397,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_custom_file.json", bindApi(api.Transactions.TransactionParseImportCustomFileDataHandler))
|
||||
apiV1Route.POST("/transactions/parse_import.json", bindApi(api.Transactions.TransactionParseImportFileHandler))
|
||||
apiV1Route.POST("/transactions/import.json", bindApi(api.Transactions.TransactionImportHandler))
|
||||
apiV1Route.GET("/transactions/import/process.json", bindApi(api.Transactions.TransactionImportProcessHandler))
|
||||
|
||||
+38
-4
@@ -15,7 +15,7 @@ http_port = 8080
|
||||
# The domain name used to access ezBookkeeping
|
||||
domain = localhost
|
||||
|
||||
# The full url used to access ezBookkeeping in browser
|
||||
# The full url used to access ezBookkeeping in browser, supports placeholders: %(protocol)s, %(domain)s, %(http_port)s
|
||||
root_url = %(protocol)s://%(domain)s:%(http_port)s/
|
||||
|
||||
# https certification and its key file
|
||||
@@ -169,7 +169,7 @@ transaction_from_ai_image_recognition = false
|
||||
max_ai_recognition_picture_size = 10485760
|
||||
|
||||
[llm_image_recognition]
|
||||
# Large Language Model (LLM) provider for receipt image recognition, supports the following types: "openai", "openai_compatible", "openrouter", "ollama", "google_ai"
|
||||
# Large Language Model (LLM) provider for receipt image recognition, supports the following types: "openai", "openai_compatible", "anthropic", "anthropic_compatible", "openrouter", "ollama", "lm_studio", "google_ai"
|
||||
llm_provider =
|
||||
|
||||
# For "openai" llm provider only, OpenAI API secret key, please visit https://platform.openai.com/api-keys for more information
|
||||
@@ -187,6 +187,30 @@ openai_compatible_api_key =
|
||||
# For "openai_compatible" llm provider only, receipt image recognition model for creating transactions from images
|
||||
openai_compatible_model_id =
|
||||
|
||||
# For "anthropic" llm provider only, Anthropic API key, please visit https://platform.claude.com/settings/keys for more information
|
||||
anthropic_api_key =
|
||||
|
||||
# For "anthropic" llm provider only, receipt image recognition model for creating transactions from images
|
||||
anthropic_model_id =
|
||||
|
||||
# For "anthropic" llm provider only, maximum allowed number of generated tokens for creating transactions from images, default is 1024
|
||||
anthropic_max_tokens = 1024
|
||||
|
||||
# For "anthropic_compatible" llm provider only, Anthropic compatible API base url, e.g. "https://api.anthropic.com/v1/"
|
||||
anthropic_compatible_base_url =
|
||||
|
||||
# For "anthropic_compatible" llm provider only, Anthropic compatible API version, e.g. "2023-06-01". If the LLM service does not require API versioning, leave it blank
|
||||
anthropic_compatible_api_version =
|
||||
|
||||
# For "anthropic_compatible" llm provider only, Anthropic compatible API secret key
|
||||
anthropic_compatible_api_key =
|
||||
|
||||
# For "anthropic_compatible" llm provider only, receipt image recognition model for creating transactions from images
|
||||
anthropic_compatible_model_id =
|
||||
|
||||
# For "anthropic_compatible" llm provider only, maximum allowed number of generated tokens for creating transactions from images, default is 1024
|
||||
anthropic_compatible_max_tokens = 1024
|
||||
|
||||
# For "openrouter" llm provider only, OpenRouter API key, please visit https://openrouter.ai/settings/keys for more information
|
||||
openrouter_api_key =
|
||||
|
||||
@@ -199,6 +223,15 @@ ollama_server_url =
|
||||
# For "ollama" llm provider only, receipt image recognition model for creating transactions from images
|
||||
ollama_model_id =
|
||||
|
||||
# For "lm_studio" llm provider only, LM Studio server url, e.g. "http://127.0.0.1:1234/"
|
||||
lm_studio_server_url =
|
||||
|
||||
# For "lm_studio" llm provider only, LM Studio API token, if "require authentication" is not enabled in LM Studio, leave it blank
|
||||
lm_studio_token =
|
||||
|
||||
# For "lm_studio" llm provider only, receipt image recognition model for creating transactions from images
|
||||
lm_studio_model_id =
|
||||
|
||||
# For "google_ai" llm provider only, Google AI Studio API key, please visit https://aistudio.google.com/apikey for more information
|
||||
google_ai_api_key =
|
||||
|
||||
@@ -263,6 +296,9 @@ password_reset_token_expired_time = 3600
|
||||
# Set to true to enable API token generation
|
||||
enable_api_token = false
|
||||
|
||||
# Allowed remote IPs for using the API token, a comma-separated list of allowed remote IPs (asterisk * for any addresses, e.g. 192.168.1.* means any IPs in the 192.168.1.x subnet), leave blank to allow all remote IPs
|
||||
api_token_allowed_remote_ips =
|
||||
|
||||
# Maximum count of password / token check failures (0 - 4294967295) per IP per minute (use the above duplicate checker), default is 5, set to 0 to disable
|
||||
max_failures_per_ip_per_minute = 5
|
||||
|
||||
@@ -493,7 +529,6 @@ custom_map_tile_server_default_zoom_level = 14
|
||||
|
||||
[exchange_rates]
|
||||
# Exchange rates data source, supports the following types:
|
||||
# "reserve_bank_of_australia": https://www.rba.gov.au/statistics/frequency/exchange-rates.html
|
||||
# "bank_of_canada": https://www.bankofcanada.ca/rates/exchange/daily-exchange-rates/
|
||||
# "czech_national_bank": https://www.cnb.cz/en/financial-markets/foreign-exchange-market/central-bank-exchange-rate-fixing/central-bank-exchange-rate-fixing/
|
||||
# "danmarks_national_bank": https://www.nationalbanken.dk/en/what-we-do/stable-prices-monetary-policy-and-the-danish-economy/exchange-rates
|
||||
@@ -509,7 +544,6 @@ custom_map_tile_server_default_zoom_level = 14
|
||||
# "swiss_national_bank": https://www.snb.ch/en/the-snb/mandates-goals/statistics/statistics-pub/current_interest_exchange_rates
|
||||
# "national_bank_of_ukraine": https://bank.gov.ua/ua/markets/exchangerates
|
||||
# "central_bank_of_uzbekistan": https://cbu.uz/en/arkhiv-kursov-valyut/
|
||||
# "international_monetary_fund": https://www.imf.org/external/np/fin/data/param_rms_mth.aspx
|
||||
# "user_custom": users set their own exchange rates data in the UI
|
||||
data_source = euro_central_bank
|
||||
|
||||
|
||||
+12
-4
@@ -9,7 +9,8 @@
|
||||
"lvdou-bing",
|
||||
"dshemin",
|
||||
"lucdsouza",
|
||||
"OuIChien"
|
||||
"OuIChien",
|
||||
"RasterCrow"
|
||||
],
|
||||
"translators": {
|
||||
"de": [
|
||||
@@ -18,7 +19,9 @@
|
||||
"en": [],
|
||||
"es": [
|
||||
"Miguelonlonlon",
|
||||
"abrugues"
|
||||
"abrugues",
|
||||
"AndresTeller",
|
||||
"diegofercri"
|
||||
],
|
||||
"fr": [
|
||||
"brieucdlf"
|
||||
@@ -39,14 +42,19 @@
|
||||
"automagics"
|
||||
],
|
||||
"pt-BR": [
|
||||
"thecodergus"
|
||||
"thecodergus",
|
||||
"balaios"
|
||||
],
|
||||
"ru": [
|
||||
"artegoser"
|
||||
"artegoser",
|
||||
"dshemin"
|
||||
],
|
||||
"sl": [
|
||||
"thehijacker"
|
||||
],
|
||||
"ta": [
|
||||
"hhharsha36"
|
||||
],
|
||||
"th": [
|
||||
"natthavat28"
|
||||
],
|
||||
|
||||
+4
-4
@@ -10,7 +10,7 @@ import (
|
||||
"github.com/urfave/cli/v3"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/cmd"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
||||
)
|
||||
|
||||
@@ -26,9 +26,9 @@ var (
|
||||
)
|
||||
|
||||
func main() {
|
||||
settings.Version = Version
|
||||
settings.CommitHash = CommitHash
|
||||
settings.BuildTime = BuildUnixTime
|
||||
core.Version = Version
|
||||
core.CommitHash = CommitHash
|
||||
core.BuildTime = BuildUnixTime
|
||||
|
||||
cmd := &cli.Command{
|
||||
Name: "ezBookkeeping",
|
||||
|
||||
@@ -9,26 +9,26 @@ require (
|
||||
github.com/gin-contrib/cache v1.4.1
|
||||
github.com/gin-contrib/gzip v1.2.5
|
||||
github.com/gin-gonic/gin v1.11.0
|
||||
github.com/go-co-op/gocron/v2 v2.18.2
|
||||
github.com/go-playground/validator/v10 v10.28.0
|
||||
github.com/go-co-op/gocron/v2 v2.19.1
|
||||
github.com/go-playground/validator/v10 v10.30.1
|
||||
github.com/go-sql-driver/mysql v1.9.3
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/invopop/jsonschema v0.13.0
|
||||
github.com/lib/pq v1.10.9
|
||||
github.com/mattn/go-sqlite3 v1.14.32
|
||||
github.com/minio/minio-go/v7 v7.0.97
|
||||
github.com/lib/pq v1.11.1
|
||||
github.com/mattn/go-sqlite3 v1.14.33
|
||||
github.com/minio/minio-go/v7 v7.0.98
|
||||
github.com/patrickmn/go-cache v2.1.0+incompatible
|
||||
github.com/pquerna/otp v1.5.0
|
||||
github.com/sirupsen/logrus v1.9.3
|
||||
github.com/sirupsen/logrus v1.9.4
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/urfave/cli/v3 v3.6.1
|
||||
github.com/urfave/cli/v3 v3.6.2
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8
|
||||
github.com/xuri/excelize/v2 v2.10.0
|
||||
golang.org/x/crypto v0.46.0
|
||||
golang.org/x/net v0.48.0
|
||||
golang.org/x/crypto v0.47.0
|
||||
golang.org/x/net v0.49.0
|
||||
golang.org/x/oauth2 v0.34.0
|
||||
golang.org/x/text v0.32.0
|
||||
gopkg.in/ini.v1 v1.67.0
|
||||
golang.org/x/text v0.33.0
|
||||
gopkg.in/ini.v1 v1.67.1
|
||||
gopkg.in/mail.v2 v2.3.1
|
||||
xorm.io/builder v0.3.13
|
||||
xorm.io/xorm v1.3.11
|
||||
@@ -52,7 +52,7 @@ require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/extrame/goyymmdd v0.0.0-20210114090516-7cc815f00d1a // indirect
|
||||
github.com/extrame/ole2 v0.0.0-20160812065207-d69429661ad7 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.10 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/go-ini/ini v1.67.0 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
|
||||
@@ -65,14 +65,14 @@ require (
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/jonboulle/clockwork v0.5.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/compress v1.18.0 // indirect
|
||||
github.com/klauspost/compress v1.18.2 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/klauspost/crc32 v1.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mailru/easyjson v0.7.7 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/memcachier/mc/v3 v3.0.3 // indirect
|
||||
github.com/minio/crc64nvme v1.1.0 // indirect
|
||||
github.com/minio/crc64nvme v1.1.1 // indirect
|
||||
github.com/minio/md5-simd v1.1.2 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
@@ -91,18 +91,19 @@ require (
|
||||
github.com/syndtr/goleveldb v1.0.0 // indirect
|
||||
github.com/tealeg/xlsx v1.0.5 // indirect
|
||||
github.com/tiendc/go-deepcopy v1.7.1 // indirect
|
||||
github.com/tinylib/msgp v1.3.0 // indirect
|
||||
github.com/tinylib/msgp v1.6.1 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.0 // indirect
|
||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
|
||||
github.com/xuri/efp v0.0.1 // indirect
|
||||
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/arch v0.22.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 // indirect
|
||||
golang.org/x/mod v0.30.0 // indirect
|
||||
golang.org/x/mod v0.31.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.39.0 // indirect
|
||||
golang.org/x/tools v0.39.0 // indirect
|
||||
golang.org/x/sys v0.40.0 // indirect
|
||||
golang.org/x/tools v0.40.0 // indirect
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
|
||||
@@ -43,8 +43,8 @@ github.com/extrame/ole2 v0.0.0-20160812065207-d69429661ad7/go.mod h1:GPpMrAfHdb8
|
||||
github.com/extrame/xls v0.0.2-0.20200426124601-4a6cf263071b h1:jqW/h4gcXYEB6kVf6iuxjU9ONWA0ugUB94TP9UNmgdg=
|
||||
github.com/extrame/xls v0.0.2-0.20200426124601-4a6cf263071b/go.mod h1:iACcgahst7BboCpIMSpnFs4SKyU9ZjsvZBfNbUxZOJI=
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gin-contrib/cache v1.4.1 h1:HcLwLfw7p+FasNp5VAnFbbBj9SzB4bDtswvon7wYSg4=
|
||||
github.com/gin-contrib/cache v1.4.1/go.mod h1:tykDV+FgItJHYEO0eCasuRYsZKPPyb4BYhAjuTlG6RM=
|
||||
github.com/gin-contrib/gzip v1.2.5 h1:fIZs0S+l17pIu1P5XRJOo/YNqfIuPCrZZ3TWB7pjckI=
|
||||
@@ -53,8 +53,8 @@ github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
|
||||
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
|
||||
github.com/go-co-op/gocron/v2 v2.18.2 h1:+5VU41FUXPWSPKLXZQ/77SGzUiPCcakU0v7ENc2H20Q=
|
||||
github.com/go-co-op/gocron/v2 v2.18.2/go.mod h1:Zii6he+Zfgy5W9B+JKk/KwejFOW0kZTFvHtwIpR4aBI=
|
||||
github.com/go-co-op/gocron/v2 v2.19.1 h1:B4iLeA0NB/2iO3EKQ7NfKn5KsQgZfjb2fkvoZJU3yBI=
|
||||
github.com/go-co-op/gocron/v2 v2.19.1/go.mod h1:5lEiCKk1oVJV39Zg7/YG10OnaVrDAV5GGR6O0663k6U=
|
||||
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
|
||||
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
|
||||
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
|
||||
@@ -63,16 +63,16 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688=
|
||||
github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU=
|
||||
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
|
||||
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
||||
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
|
||||
@@ -90,8 +90,8 @@ github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7X
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
|
||||
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
@@ -104,22 +104,22 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/lib/pq v1.11.1 h1:wuChtj2hfsGmmx3nf1m7xC2XpK6OtelS2shMY+bGMtI=
|
||||
github.com/lib/pq v1.11.1/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
|
||||
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
||||
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
|
||||
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0=
|
||||
github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/memcachier/mc/v3 v3.0.3 h1:qii+lDiPKi36O4Xg+HVKwHu6Oq+Gt17b+uEiA0Drwv4=
|
||||
github.com/memcachier/mc/v3 v3.0.3/go.mod h1:GzjocBahcXPxt2cmqzknrgqCOmMxiSzhVKPOe90Tpug=
|
||||
github.com/minio/crc64nvme v1.1.0 h1:e/tAguZ+4cw32D+IO/8GSf5UVr9y+3eJcxZI2WOO/7Q=
|
||||
github.com/minio/crc64nvme v1.1.0/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
|
||||
github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI=
|
||||
github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
|
||||
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
|
||||
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
|
||||
github.com/minio/minio-go/v7 v7.0.97 h1:lqhREPyfgHTB/ciX8k2r8k0D93WaFqxbJX36UZq5occ=
|
||||
github.com/minio/minio-go/v7 v7.0.97/go.mod h1:re5VXuo0pwEtoNLsNuSr0RrLfT/MBtohwdaSmPPSRSk=
|
||||
github.com/minio/minio-go/v7 v7.0.98 h1:MeAVKjLVz+XJ28zFcuYyImNSAh8Mq725uNW4beRisi0=
|
||||
github.com/minio/minio-go/v7 v7.0.98/go.mod h1:cY0Y+W7yozf0mdIclrttzo1Iiu7mEf9y7nk2uXqMOvM=
|
||||
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=
|
||||
@@ -155,16 +155,18 @@ github.com/robfig/go-cache v0.0.0-20130306151617-9fc39e0dbf62/go.mod h1:65XQgovT
|
||||
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
|
||||
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
|
||||
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/syndtr/goleveldb v1.0.0 h1:fBdIW9lB4Iz0n9khmH8w27SJ3QEJ7+IgjPEwGSZiFdE=
|
||||
@@ -173,14 +175,14 @@ github.com/tealeg/xlsx v1.0.5 h1:+f8oFmvY8Gw1iUXzPk+kz+4GpbDZPK1FhPiQRd+ypgE=
|
||||
github.com/tealeg/xlsx v1.0.5/go.mod h1:btRS8dz54TDnvKNosuAqxrM1QgN1udgk9O34bDCnORM=
|
||||
github.com/tiendc/go-deepcopy v1.7.1 h1:LnubftI6nYaaMOcaz0LphzwraqN8jiWTwm416sitff4=
|
||||
github.com/tiendc/go-deepcopy v1.7.1/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ=
|
||||
github.com/tinylib/msgp v1.3.0 h1:ULuf7GPooDaIlbyvgAxBV/FI7ynli6LZ1/nVUNu+0ww=
|
||||
github.com/tinylib/msgp v1.3.0/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0=
|
||||
github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY=
|
||||
github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
|
||||
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
github.com/urfave/cli/v3 v3.6.1 h1:j8Qq8NyUawj/7rTYdBGrxcH7A/j7/G8Q5LhWEW4G3Mo=
|
||||
github.com/urfave/cli/v3 v3.6.1/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso=
|
||||
github.com/urfave/cli/v3 v3.6.2 h1:lQuqiPrZ1cIz8hz+HcrG0TNZFxU70dPZ3Yl+pSrH9A8=
|
||||
github.com/urfave/cli/v3 v3.6.2/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso=
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
|
||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
|
||||
@@ -190,32 +192,33 @@ github.com/xuri/excelize/v2 v2.10.0 h1:8aKsP7JD39iKLc6dH5Tw3dgV3sPRh8uRVXu/fMstf
|
||||
github.com/xuri/excelize/v2 v2.10.0/go.mod h1:SC5TzhQkaOsTWpANfm+7bJCldzcnU/jrhqkTi/iBHBU=
|
||||
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE=
|
||||
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
|
||||
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
||||
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
||||
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
||||
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
||||
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8/go.mod h1:jj3sYF3dwk5D+ghuXyeI3r5MFf+NT2An6/9dOA95KSI=
|
||||
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
|
||||
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
|
||||
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
|
||||
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
|
||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
||||
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
||||
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
|
||||
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
|
||||
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
|
||||
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
||||
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
||||
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
|
||||
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk=
|
||||
@@ -223,8 +226,8 @@ gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
||||
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
|
||||
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||
gopkg.in/ini.v1 v1.67.1 h1:tVBILHy0R6e4wkYOn3XmiITt/hEVH4TFMYvAX2Ytz6k=
|
||||
gopkg.in/ini.v1 v1.67.1/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss=
|
||||
gopkg.in/mail.v2 v2.3.1 h1:WYFn/oANrAGP2C0dcV6/pbkPzv8yGzqTjPmTeO7qoXk=
|
||||
gopkg.in/mail.v2 v2.3.1/go.mod h1:htwXN1Qh09vZJ1NVKxQqHPBaCBbzKhp5GzuJEA4VJWw=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
||||
|
||||
Generated
+2022
-1518
File diff suppressed because it is too large
Load Diff
+17
-17
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ezbookkeeping",
|
||||
"version": "1.3.0",
|
||||
"version": "1.4.0",
|
||||
"private": true,
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -21,16 +21,16 @@
|
||||
"dependencies": {
|
||||
"@mdi/js": "^7.4.47",
|
||||
"@vuepic/vue-datepicker": "^12.1.0",
|
||||
"axios": "^1.13.2",
|
||||
"axios": "^1.13.4",
|
||||
"cbor-js": "^0.1.0",
|
||||
"chardet": "^2.1.1",
|
||||
"clipboard": "^2.0.11",
|
||||
"crypto-js": "^4.2.0",
|
||||
"dom7": "^4.0.6",
|
||||
"echarts": "^6.0.0",
|
||||
"framework7": "^9.0.2",
|
||||
"framework7": "^9.0.3",
|
||||
"framework7-icons": "^5.0.5",
|
||||
"framework7-vue": "^9.0.2",
|
||||
"framework7-vue": "^9.0.3",
|
||||
"jalaali-js": "^1.2.8",
|
||||
"leaflet": "^1.9.4",
|
||||
"line-awesome": "^1.3.0",
|
||||
@@ -39,19 +39,19 @@
|
||||
"pinia": "^3.0.4",
|
||||
"register-service-worker": "^1.7.2",
|
||||
"skeleton-elements": "^4.0.1",
|
||||
"swiper": "^12.0.3",
|
||||
"swiper": "^12.1.0",
|
||||
"ua-parser-js": "^1.0.39",
|
||||
"vue": "^3.5.25",
|
||||
"vue": "^3.5.27",
|
||||
"vue-echarts": "^8.0.1",
|
||||
"vue-i18n": "^11.2.2",
|
||||
"vue-router": "^4.6.4",
|
||||
"vue-i18n": "^11.2.8",
|
||||
"vue-router": "^5.0.2",
|
||||
"vue3-perfect-scrollbar": "^2.0.0",
|
||||
"vuedraggable": "^4.1.0",
|
||||
"vuetify": "^3.11.3"
|
||||
"vuetify": "^3.11.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@jest/globals": "^30.2.0",
|
||||
"@tsconfig/node24": "^24.0.3",
|
||||
"@tsconfig/node24": "^24.0.4",
|
||||
"@types/cbor-js": "^0.1.1",
|
||||
"@types/crypto-js": "^4.2.2",
|
||||
"@types/git-rev-sync": "^2.0.2",
|
||||
@@ -59,24 +59,24 @@
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^24.1.0",
|
||||
"@types/ua-parser-js": "^0.7.39",
|
||||
"@vitejs/plugin-vue": "^6.0.3",
|
||||
"@vitejs/plugin-vue": "^6.0.4",
|
||||
"@vue/eslint-config-typescript": "^14.6.0",
|
||||
"@vue/tsconfig": "^0.8.1",
|
||||
"cross-env": "^10.1.0",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-vue": "^10.6.2",
|
||||
"eslint-plugin-vue": "^10.7.0",
|
||||
"git-rev-sync": "^3.0.2",
|
||||
"jest": "^30.2.0",
|
||||
"postcss-preset-env": "^10.5.0",
|
||||
"sass": "^1.96.0",
|
||||
"postcss-preset-env": "^11.1.3",
|
||||
"sass": "^1.97.3",
|
||||
"ts-jest": "^29.4.6",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.2.7",
|
||||
"vite": "^7.3.1",
|
||||
"vite-plugin-checker": "^0.12.0",
|
||||
"vite-plugin-pwa": "^1.2.0",
|
||||
"vite-plugin-vuetify": "^2.1.2",
|
||||
"vue-tsc": "^3.1.8"
|
||||
"vite-plugin-vuetify": "^2.1.3",
|
||||
"vue-tsc": "^3.2.4"
|
||||
},
|
||||
"browserslist": [
|
||||
"last 5 Chrome versions",
|
||||
|
||||
+2
-3
@@ -3,7 +3,6 @@ package api
|
||||
import (
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
||||
)
|
||||
|
||||
// HealthsApi represents health api
|
||||
@@ -18,8 +17,8 @@ var (
|
||||
func (a *HealthsApi) HealthStatusHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
result := make(map[string]string)
|
||||
|
||||
result["version"] = settings.Version
|
||||
result["commit"] = settings.CommitHash
|
||||
result["version"] = core.Version
|
||||
result["commit"] = core.CommitHash
|
||||
result["status"] = "ok"
|
||||
|
||||
return result, nil
|
||||
|
||||
@@ -103,7 +103,7 @@ func (a *ModelContextProtocolAPI) InitializeHandler(c *core.WebContext, jsonRPCR
|
||||
ServerInfo: &mcp.MCPImplementation{
|
||||
Name: mcpServerName,
|
||||
Title: core.ApplicationName,
|
||||
Version: settings.Version,
|
||||
Version: core.Version,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
+4
-5
@@ -3,7 +3,6 @@ package api
|
||||
import (
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
||||
)
|
||||
|
||||
// SystemsApi represents system api
|
||||
@@ -18,11 +17,11 @@ var (
|
||||
func (a *SystemsApi) VersionHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
result := make(map[string]string)
|
||||
|
||||
result["version"] = settings.Version
|
||||
result["commitHash"] = settings.CommitHash
|
||||
result["version"] = core.Version
|
||||
result["commitHash"] = core.CommitHash
|
||||
|
||||
if settings.BuildTime != "" {
|
||||
result["buildTime"] = settings.BuildTime
|
||||
if core.BuildTime != "" {
|
||||
result["buildTime"] = core.BuildTime
|
||||
}
|
||||
|
||||
return result, nil
|
||||
|
||||
+4
-4
@@ -69,10 +69,10 @@ func (a *TokensApi) TokenListHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
tokenResp.IsCurrent = true
|
||||
}
|
||||
|
||||
if token.TokenType == core.USER_TOKEN_TYPE_API && token.UserAgent != services.TokenUserAgentCreatedViaCli {
|
||||
tokenResp.UserAgent = services.TokenUserAgentForAPI
|
||||
} else if token.TokenType == core.USER_TOKEN_TYPE_MCP && token.UserAgent != services.TokenUserAgentCreatedViaCli {
|
||||
tokenResp.UserAgent = services.TokenUserAgentForMCP
|
||||
if token.TokenType == core.USER_TOKEN_TYPE_API && token.UserAgent != core.TokenUserAgentCreatedViaCli {
|
||||
tokenResp.UserAgent = core.TokenUserAgentForAPI
|
||||
} else if token.TokenType == core.USER_TOKEN_TYPE_MCP && token.UserAgent != core.TokenUserAgentCreatedViaCli {
|
||||
tokenResp.UserAgent = core.TokenUserAgentForMCP
|
||||
}
|
||||
|
||||
tokenResps[i] = tokenResp
|
||||
|
||||
@@ -12,13 +12,15 @@ import (
|
||||
|
||||
// TransactionTagsApi represents transaction tag api
|
||||
type TransactionTagsApi struct {
|
||||
tags *services.TransactionTagService
|
||||
tags *services.TransactionTagService
|
||||
tagGroups *services.TransactionTagGroupService
|
||||
}
|
||||
|
||||
// Initialize a transaction tag api singleton instance
|
||||
var (
|
||||
TransactionTags = &TransactionTagsApi{
|
||||
tags: services.TransactionTags,
|
||||
tags: services.TransactionTags,
|
||||
tagGroups: services.TransactionTagGroups,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -78,6 +80,20 @@ func (a *TransactionTagsApi) TagCreateHandler(c *core.WebContext) (any, *errs.Er
|
||||
|
||||
uid := c.GetCurrentUid()
|
||||
|
||||
if tagCreateReq.GroupId > 0 {
|
||||
tagGroup, err := a.tagGroups.GetTagGroupByTagGroupId(c, uid, tagCreateReq.GroupId)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transaction_tags.TagCreateHandler] failed to get tag group \"id:%d\" for user \"uid:%d\", because %s", tagCreateReq.GroupId, uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
if tagGroup == nil {
|
||||
log.Warnf(c, "[transaction_tags.TagCreateHandler] the tag group \"id:%d\" does not exist for user \"uid:%d\"", tagCreateReq.GroupId, uid)
|
||||
return nil, errs.ErrTransactionTagGroupNotFound
|
||||
}
|
||||
}
|
||||
|
||||
maxOrderId, err := a.tags.GetMaxDisplayOrder(c, uid, tagCreateReq.GroupId)
|
||||
|
||||
if err != nil {
|
||||
@@ -120,6 +136,20 @@ func (a *TransactionTagsApi) TagCreateBatchHandler(c *core.WebContext) (any, *er
|
||||
|
||||
uid := c.GetCurrentUid()
|
||||
|
||||
if tagCreateBatchReq.GroupId > 0 {
|
||||
tagGroup, err := a.tagGroups.GetTagGroupByTagGroupId(c, uid, tagCreateBatchReq.GroupId)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transaction_tags.TagCreateBatchHandler] failed to get tag group \"id:%d\" for user \"uid:%d\", because %s", tagCreateBatchReq.GroupId, uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
if tagGroup == nil {
|
||||
log.Warnf(c, "[transaction_tags.TagCreateBatchHandler] the tag group \"id:%d\" does not exist for user \"uid:%d\"", tagCreateBatchReq.GroupId, uid)
|
||||
return nil, errs.ErrTransactionTagGroupNotFound
|
||||
}
|
||||
}
|
||||
|
||||
maxOrderId, err := a.tags.GetMaxDisplayOrder(c, uid, tagCreateBatchReq.GroupId)
|
||||
|
||||
if err != nil {
|
||||
@@ -167,6 +197,20 @@ func (a *TransactionTagsApi) TagModifyHandler(c *core.WebContext) (any, *errs.Er
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
if tagModifyReq.GroupId != tag.TagGroupId && tagModifyReq.GroupId > 0 {
|
||||
tagGroup, err := a.tagGroups.GetTagGroupByTagGroupId(c, uid, tagModifyReq.GroupId)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transaction_tags.TagModifyHandler] failed to get tag group \"id:%d\" for user \"uid:%d\", because %s", tagModifyReq.GroupId, uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
if tagGroup == nil {
|
||||
log.Warnf(c, "[transaction_tags.TagModifyHandler] the tag group \"id:%d\" does not exist for user \"uid:%d\"", tagModifyReq.GroupId, uid)
|
||||
return nil, errs.ErrTransactionTagGroupNotFound
|
||||
}
|
||||
}
|
||||
|
||||
newTag := &models.TransactionTag{
|
||||
TagId: tag.TagId,
|
||||
Uid: uid,
|
||||
|
||||
+149
-47
@@ -192,7 +192,15 @@ func (a *TransactionsApi) TransactionListHandler(c *core.WebContext) (any, *errs
|
||||
transactions = transactions[:transactionListReq.Count]
|
||||
}
|
||||
|
||||
transactionResult, err := a.getTransactionResponseListResult(c, user, transactions, clientTimezone, transactionListReq.WithPictures, transactionListReq.TrimAccount, transactionListReq.TrimCategory, transactionListReq.TrimTag)
|
||||
accountMap, categoryMap, tagMap, allTransactionTagIds, pictureInfoMap, err := a.getTransactionEssentialDataByTransactionIds(c, user, transactions, transactionListReq.WithPictures, transactionListReq.TrimCategory, transactionListReq.TrimTag)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.TransactionListHandler] failed to get essential data for assembling transaction result for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
transactions = a.filterTransactions(c, uid, transactions, accountMap)
|
||||
transactionResult, err := a.getTransactionResponseListResult(c, user, transactions, accountMap, categoryMap, tagMap, allTransactionTagIds, pictureInfoMap, clientTimezone, transactionListReq.WithPictures, transactionListReq.TrimAccount, transactionListReq.TrimCategory, transactionListReq.TrimTag)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.TransactionListHandler] failed to assemble transaction result for user \"uid:%d\", because %s", uid, err.Error())
|
||||
@@ -275,7 +283,15 @@ func (a *TransactionsApi) TransactionMonthListHandler(c *core.WebContext) (any,
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
transactionResult, err := a.getTransactionResponseListResult(c, user, transactions, clientTimezone, transactionListReq.WithPictures, transactionListReq.TrimAccount, transactionListReq.TrimCategory, transactionListReq.TrimTag)
|
||||
accountMap, categoryMap, tagMap, allTransactionTagIds, pictureInfoMap, err := a.getTransactionEssentialDataByTransactionIds(c, user, transactions, transactionListReq.WithPictures, transactionListReq.TrimCategory, transactionListReq.TrimTag)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.TransactionMonthListHandler] failed to get essential data for assembling transaction result for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
transactions = a.filterTransactions(c, uid, transactions, accountMap)
|
||||
transactionResult, err := a.getTransactionResponseListResult(c, user, transactions, accountMap, categoryMap, tagMap, allTransactionTagIds, pictureInfoMap, clientTimezone, transactionListReq.WithPictures, transactionListReq.TrimAccount, transactionListReq.TrimCategory, transactionListReq.TrimTag)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.TransactionMonthListHandler] failed to assemble transaction result for user \"uid:%d\", because %s", uid, err.Error())
|
||||
@@ -362,7 +378,25 @@ func (a *TransactionsApi) TransactionListAllHandler(c *core.WebContext) (any, *e
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
transactionResult, err := a.getTransactionResponseListResult(c, user, allTransactions, clientTimezone, transactionAllListReq.WithPictures, transactionAllListReq.TrimAccount, transactionAllListReq.TrimCategory, transactionAllListReq.TrimTag)
|
||||
var accountMap map[int64]*models.Account
|
||||
var categoryMap map[int64]*models.TransactionCategory
|
||||
var tagMap map[int64]*models.TransactionTag
|
||||
var allTransactionTagIds map[int64][]int64
|
||||
var pictureInfoMap map[int64][]*models.TransactionPictureInfo
|
||||
|
||||
if minTransactionTime == 0 && maxTransactionTime == math.MaxInt64 && len(allCategoryIds) < 1 && len(allAccountIds) < 1 && len(tagFilters) < 1 && transactionAllListReq.AmountFilter == "" && transactionAllListReq.Keyword == "" {
|
||||
accountMap, categoryMap, tagMap, allTransactionTagIds, pictureInfoMap, err = a.getTransactionAllEssentialData(c, user, transactionAllListReq.WithPictures, transactionAllListReq.TrimCategory, transactionAllListReq.TrimTag)
|
||||
} else {
|
||||
accountMap, categoryMap, tagMap, allTransactionTagIds, pictureInfoMap, err = a.getTransactionEssentialDataByTransactionIds(c, user, allTransactions, transactionAllListReq.WithPictures, transactionAllListReq.TrimCategory, transactionAllListReq.TrimTag)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.TransactionListAllHandler] failed to get essential data for assembling transaction result for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
allTransactions = a.filterTransactions(c, uid, allTransactions, accountMap)
|
||||
transactionResult, err := a.getTransactionResponseListResult(c, user, allTransactions, accountMap, categoryMap, tagMap, allTransactionTagIds, pictureInfoMap, clientTimezone, transactionAllListReq.WithPictures, transactionAllListReq.TrimAccount, transactionAllListReq.TrimCategory, transactionAllListReq.TrimTag)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.TransactionListAllHandler] failed to assemble transaction result for user \"uid:%d\", because %s", uid, err.Error())
|
||||
@@ -441,7 +475,24 @@ func (a *TransactionsApi) TransactionReconciliationStatementHandler(c *core.WebC
|
||||
transactionAccountBalanceMap[transactionWithBalance.RelatedId] = transactionWithBalance
|
||||
}
|
||||
|
||||
transactionResult, err := a.getTransactionResponseListResult(c, user, transactions, clientTimezone, false, true, true, true)
|
||||
allAccountIds := make([]int64, 0, len(transactions)*2)
|
||||
|
||||
for i := 0; i < len(transactions); i++ {
|
||||
allAccountIds = append(allAccountIds, transactions[i].AccountId)
|
||||
|
||||
if transactions[i].Type == models.TRANSACTION_DB_TYPE_TRANSFER_IN || transactions[i].Type == models.TRANSACTION_DB_TYPE_TRANSFER_OUT {
|
||||
allAccountIds = append(allAccountIds, transactions[i].RelatedAccountId)
|
||||
}
|
||||
}
|
||||
|
||||
allAccounts, err := a.accounts.GetAccountsByAccountIds(c, uid, utils.ToUniqueInt64Slice(allAccountIds))
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.TransactionReconciliationStatementHandler] failed to get essential data for assembling transaction result for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
transactionResult, err := a.getTransactionResponseListResult(c, user, transactions, allAccounts, nil, nil, nil, nil, clientTimezone, false, true, true, true)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.TransactionReconciliationStatementHandler] failed to assemble transaction result for user \"uid:%d\", because %s", uid, err.Error())
|
||||
@@ -1401,13 +1452,13 @@ func (a *TransactionsApi) TransactionDeleteHandler(c *core.WebContext) (any, *er
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// TransactionParseImportDsvFileDataHandler returns the parsed file data by request parameters for current user
|
||||
func (a *TransactionsApi) TransactionParseImportDsvFileDataHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
// TransactionParseImportCustomFileDataHandler returns the parsed file data by request parameters for current user
|
||||
func (a *TransactionsApi) TransactionParseImportCustomFileDataHandler(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())
|
||||
log.Errorf(c, "[transactions.TransactionParseImportCustomFileDataHandler] failed to get multi-part form data for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.ErrParameterInvalid
|
||||
}
|
||||
|
||||
@@ -1419,18 +1470,18 @@ func (a *TransactionsApi) TransactionParseImportDsvFileDataHandler(c *core.WebCo
|
||||
|
||||
fileType := fileTypes[0]
|
||||
|
||||
if !converters.IsCustomDelimiterSeparatedValuesFileType(fileType) {
|
||||
if !converters.IsCustomFileFormatFileType(fileType) {
|
||||
return nil, errs.Or(err, errs.ErrImportFileTypeNotSupported)
|
||||
}
|
||||
|
||||
fileEncodings := form.Value["fileEncoding"]
|
||||
fileEncoding := ""
|
||||
|
||||
if len(fileEncodings) < 1 || fileEncodings[0] == "" {
|
||||
return nil, errs.ErrImportFileEncodingIsEmpty
|
||||
if len(fileEncodings) > 0 {
|
||||
fileEncoding = fileEncodings[0]
|
||||
}
|
||||
|
||||
fileEncoding := fileEncodings[0]
|
||||
dataParser, err := converters.CreateNewDelimiterSeparatedValuesDataParser(fileType, fileEncoding)
|
||||
dataParser, err := converters.CreateNewCustomFileFormatTransactionDataParser(fileType, fileEncoding)
|
||||
|
||||
if err != nil {
|
||||
return nil, errs.Or(err, errs.ErrImportFileTypeNotSupported)
|
||||
@@ -1439,24 +1490,24 @@ func (a *TransactionsApi) TransactionParseImportDsvFileDataHandler(c *core.WebCo
|
||||
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)
|
||||
log.Warnf(c, "[transactions.TransactionParseImportCustomFileDataHandler] 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)
|
||||
log.Warnf(c, "[transactions.TransactionParseImportCustomFileDataHandler] 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)
|
||||
log.Warnf(c, "[transactions.TransactionParseImportCustomFileDataHandler] the upload file size \"%d\" exceeds the maximum size \"%d\" of import file for user \"uid:%d\"", importFiles[0].Size, a.CurrentConfig().MaxImportFileSize, uid)
|
||||
return nil, errs.ErrExceedMaxUploadFileSize
|
||||
}
|
||||
|
||||
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())
|
||||
log.Errorf(c, "[transactions.TransactionParseImportCustomFileDataHandler] failed to get import file from request for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.ErrOperationFailed
|
||||
}
|
||||
|
||||
@@ -1464,14 +1515,14 @@ func (a *TransactionsApi) TransactionParseImportDsvFileDataHandler(c *core.WebCo
|
||||
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())
|
||||
log.Errorf(c, "[transactions.TransactionParseImportCustomFileDataHandler] failed to read import file data for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
allLines, err := dataParser.ParseDsvFileLines(c, fileData)
|
||||
allLines, err := dataParser.ParseDataLines(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())
|
||||
log.Errorf(c, "[transactions.TransactionParseImportCustomFileDataHandler] failed to parse import file data for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
@@ -1514,15 +1565,14 @@ func (a *TransactionsApi) TransactionParseImportFileHandler(c *core.WebContext)
|
||||
|
||||
var dataImporter converter.TransactionDataImporter
|
||||
|
||||
if converters.IsCustomDelimiterSeparatedValuesFileType(fileType) {
|
||||
if converters.IsCustomFileFormatFileType(fileType) {
|
||||
fileEncodings := form.Value["fileEncoding"]
|
||||
fileEncoding := ""
|
||||
|
||||
if len(fileEncodings) < 1 || fileEncodings[0] == "" {
|
||||
return nil, errs.ErrImportFileEncodingIsEmpty
|
||||
if len(fileEncodings) > 0 {
|
||||
fileEncoding = fileEncodings[0]
|
||||
}
|
||||
|
||||
fileEncoding := fileEncodings[0]
|
||||
|
||||
columnMappings := form.Value["columnMapping"]
|
||||
|
||||
if len(columnMappings) < 1 || columnMappings[0] == "" {
|
||||
@@ -1606,7 +1656,7 @@ func (a *TransactionsApi) TransactionParseImportFileHandler(c *core.WebContext)
|
||||
transactionTagSeparator = transactionTagSeparators[0]
|
||||
}
|
||||
|
||||
dataImporter, err = converters.CreateNewDelimiterSeparatedValuesDataImporter(fileType, fileEncoding, columnIndexMapping, transactionTypeNameMapping, hasHeaderLine, timeFormats[0], timezoneFormat, amountDecimalSeparator, amountDigitGroupingSymbol, geoLocationSeparator, geoLocationOrder, transactionTagSeparator)
|
||||
dataImporter, err = converters.CreateNewCustomTransactionDataImporter(fileType, fileEncoding, columnIndexMapping, transactionTypeNameMapping, hasHeaderLine, timeFormats[0], timezoneFormat, amountDecimalSeparator, amountDigitGroupingSymbol, geoLocationSeparator, geoLocationOrder, transactionTagSeparator)
|
||||
} else {
|
||||
dataImporter, err = converters.GetTransactionDataImporter(fileType)
|
||||
}
|
||||
@@ -1928,7 +1978,61 @@ func (a *TransactionsApi) getTransactionTagInfoResponses(tagIds []int64, allTran
|
||||
return allTags
|
||||
}
|
||||
|
||||
func (a *TransactionsApi) getTransactionResponseListResult(c *core.WebContext, user *models.User, transactions []*models.Transaction, clientTimezone *time.Location, withPictures bool, trimAccount bool, trimCategory bool, trimTag bool) (models.TransactionInfoResponseSlice, error) {
|
||||
func (a *TransactionsApi) getTransactionAllEssentialData(c *core.WebContext, user *models.User, withPictures bool, trimCategory bool, trimTag bool) (accountMap map[int64]*models.Account, categoryMap map[int64]*models.TransactionCategory, tagMap map[int64]*models.TransactionTag, allTransactionTagIds map[int64][]int64, pictureInfoMap map[int64][]*models.TransactionPictureInfo, err error) {
|
||||
uid := user.Uid
|
||||
allAccounts, err := a.accounts.GetAllAccountsByUid(c, uid)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.getTransactionAllEssentialData] failed to get all accounts for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, nil, nil, nil, nil, err
|
||||
}
|
||||
|
||||
accountMap = a.accounts.GetAccountMapByList(allAccounts)
|
||||
|
||||
allTagIndexes, err := a.transactionTags.GetAllTagIdsOfAllTransactions(c, uid)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.getTransactionAllEssentialData] failed to get all transactions tag ids for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, nil, nil, nil, nil, err
|
||||
}
|
||||
|
||||
allTransactionTagIds = a.transactionTags.GetGroupedTransactionTagIds(allTagIndexes)
|
||||
|
||||
if !trimCategory {
|
||||
allCategories, err := a.transactionCategories.GetAllCategoriesByUid(c, uid, 0, -1)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.getTransactionAllEssentialData] failed to get all transactions categories for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, nil, nil, nil, nil, err
|
||||
}
|
||||
|
||||
categoryMap = a.transactionCategories.GetCategoryMapByList(allCategories)
|
||||
}
|
||||
|
||||
if !trimTag {
|
||||
allTags, err := a.transactionTags.GetAllTagsByUid(c, uid)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.getTransactionAllEssentialData] failed to get all transactions tags for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, nil, nil, nil, nil, err
|
||||
}
|
||||
|
||||
tagMap = a.transactionTags.GetTagMapByList(allTags)
|
||||
}
|
||||
|
||||
if withPictures && a.CurrentConfig().EnableTransactionPictures {
|
||||
pictureInfoMap, err = a.transactionPictures.GetAllPictureInfosOfAllTransactions(c, uid)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.getTransactionAllEssentialData] failed to get all transactions pictures for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, nil, nil, nil, nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return accountMap, categoryMap, tagMap, allTransactionTagIds, pictureInfoMap, nil
|
||||
}
|
||||
|
||||
func (a *TransactionsApi) getTransactionEssentialDataByTransactionIds(c *core.WebContext, user *models.User, transactions []*models.Transaction, withPictures bool, trimCategory bool, trimTag bool) (accountMap map[int64]*models.Account, categoryMap map[int64]*models.TransactionCategory, tagMap map[int64]*models.TransactionTag, allTransactionTagIds map[int64][]int64, pictureInfoMap map[int64][]*models.TransactionPictureInfo, err error) {
|
||||
uid := user.Uid
|
||||
transactionIds := make([]int64, len(transactions))
|
||||
accountIds := make([]int64, 0, len(transactions)*2)
|
||||
@@ -1951,32 +2055,26 @@ func (a *TransactionsApi) getTransactionResponseListResult(c *core.WebContext, u
|
||||
categoryIds = append(categoryIds, transactions[i].CategoryId)
|
||||
}
|
||||
|
||||
allAccounts, err := a.accounts.GetAccountsByAccountIds(c, uid, utils.ToUniqueInt64Slice(accountIds))
|
||||
accountMap, err = a.accounts.GetAccountsByAccountIds(c, uid, utils.ToUniqueInt64Slice(accountIds))
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.getTransactionResponseListResult] failed to get accounts for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, err
|
||||
log.Errorf(c, "[transactions.getTransactionEssentialDataByTransactionIds] failed to get accounts for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, nil, nil, nil, nil, err
|
||||
}
|
||||
|
||||
transactions = a.filterTransactions(c, uid, transactions, allAccounts)
|
||||
|
||||
allTransactionTagIds, err := a.transactionTags.GetAllTagIdsOfTransactions(c, uid, transactionIds)
|
||||
allTransactionTagIds, err = a.transactionTags.GetAllTagIdsOfTransactions(c, uid, transactionIds)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.getTransactionResponseListResult] failed to get transactions tag ids for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, err
|
||||
log.Errorf(c, "[transactions.getTransactionEssentialDataByTransactionIds] failed to get transactions tag ids for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, nil, nil, nil, nil, err
|
||||
}
|
||||
|
||||
var categoryMap map[int64]*models.TransactionCategory
|
||||
var tagMap map[int64]*models.TransactionTag
|
||||
var pictureInfoMap map[int64][]*models.TransactionPictureInfo
|
||||
|
||||
if !trimCategory {
|
||||
categoryMap, err = a.transactionCategories.GetCategoriesByCategoryIds(c, uid, utils.ToUniqueInt64Slice(categoryIds))
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.getTransactionResponseListResult] failed to get transactions categories for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, err
|
||||
log.Errorf(c, "[transactions.getTransactionEssentialDataByTransactionIds] failed to get transactions categories for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, nil, nil, nil, nil, err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1984,8 +2082,8 @@ func (a *TransactionsApi) getTransactionResponseListResult(c *core.WebContext, u
|
||||
tagMap, err = a.transactionTags.GetTagsByTagIds(c, uid, utils.ToUniqueInt64Slice(a.transactionTags.GetTransactionTagIds(allTransactionTagIds)))
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.getTransactionResponseListResult] failed to get transactions tags for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, err
|
||||
log.Errorf(c, "[transactions.getTransactionEssentialDataByTransactionIds] failed to get transactions tags for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, nil, nil, nil, nil, err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1993,11 +2091,15 @@ func (a *TransactionsApi) getTransactionResponseListResult(c *core.WebContext, u
|
||||
pictureInfoMap, err = a.transactionPictures.GetPictureInfosByTransactionIds(c, uid, utils.ToUniqueInt64Slice(a.transactions.GetTransactionIds(transactions)))
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.getTransactionResponseListResult] failed to get transactions pictures for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, err
|
||||
log.Errorf(c, "[transactions.getTransactionEssentialDataByTransactionIds] failed to get transactions pictures for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, nil, nil, nil, nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return accountMap, categoryMap, tagMap, allTransactionTagIds, pictureInfoMap, nil
|
||||
}
|
||||
|
||||
func (a *TransactionsApi) getTransactionResponseListResult(c *core.WebContext, user *models.User, transactions []*models.Transaction, allAccounts map[int64]*models.Account, categoryMap map[int64]*models.TransactionCategory, tagMap map[int64]*models.TransactionTag, allTransactionTagIds map[int64][]int64, pictureInfoMap map[int64][]*models.TransactionPictureInfo, clientTimezone *time.Location, withPictures bool, trimAccount bool, trimCategory bool, trimTag bool) (models.TransactionInfoResponseSlice, error) {
|
||||
result := make(models.TransactionInfoResponseSlice, len(transactions))
|
||||
|
||||
for i := 0; i < len(transactions); i++ {
|
||||
@@ -2021,17 +2123,17 @@ func (a *TransactionsApi) getTransactionResponseListResult(c *core.WebContext, u
|
||||
}
|
||||
}
|
||||
|
||||
if !trimCategory {
|
||||
if !trimCategory && categoryMap != nil {
|
||||
if category := categoryMap[transaction.CategoryId]; category != nil {
|
||||
result[i].Category = category.ToTransactionCategoryInfoResponse()
|
||||
}
|
||||
}
|
||||
|
||||
if !trimTag {
|
||||
if !trimTag && tagMap != nil && transactionTagIds != nil {
|
||||
result[i].Tags = a.getTransactionTagInfoResponses(transactionTagIds, tagMap)
|
||||
}
|
||||
|
||||
if withPictures && a.CurrentConfig().EnableTransactionPictures {
|
||||
if withPictures && a.CurrentConfig().EnableTransactionPictures && pictureInfoMap != nil {
|
||||
pictureInfos, exists := pictureInfoMap[transaction.TransactionId]
|
||||
|
||||
if exists {
|
||||
|
||||
@@ -67,7 +67,7 @@ func InitializeOAuth2Provider(config *settings.Config) error {
|
||||
|
||||
Container.current = oauth2Provider
|
||||
Container.usePKCE = config.OAuth2UsePKCE
|
||||
Container.oauth2HttpClient = httpclient.NewHttpClient(config.OAuth2RequestTimeout, config.OAuth2Proxy, config.OAuth2SkipTLSVerify, settings.GetUserAgent(), config.EnableDebugLog)
|
||||
Container.oauth2HttpClient = httpclient.NewHttpClient(config.OAuth2RequestTimeout, config.OAuth2Proxy, config.OAuth2SkipTLSVerify, core.GetOutgoingUserAgent(), config.EnableDebugLog)
|
||||
Container.externalUserAuthType = externalUserAuthType
|
||||
|
||||
return nil
|
||||
|
||||
@@ -150,7 +150,7 @@ func (l *UserDataCli) ModifyUserPassword(c *core.CliContext, username string, pa
|
||||
Password: password,
|
||||
}
|
||||
|
||||
_, _, err = l.users.UpdateUser(c, userNew, false)
|
||||
err = l.users.UpdateUserPassword(c, userNew)
|
||||
|
||||
if err != nil {
|
||||
log.CliErrorf(c, "[user_data.ModifyUserPassword] failed to update user \"%s\" password, because %s", user.Username, err.Error())
|
||||
|
||||
@@ -9,11 +9,20 @@ const (
|
||||
CAMT_INDICATOR_DEBIT camtCreditDebitIndicator = "DBIT"
|
||||
)
|
||||
|
||||
type camt052File struct {
|
||||
XMLName xml.Name `xml:"Document"`
|
||||
BankToCustomerAccountReport *camtBankToCustomerAccountReport `xml:"BkToCstmrAcctRpt"`
|
||||
}
|
||||
|
||||
type camt053File struct {
|
||||
XMLName xml.Name `xml:"Document"`
|
||||
BankToCustomerStatement *camtBankToCustomerStatement `xml:"BkToCstmrStmt"`
|
||||
}
|
||||
|
||||
type camtBankToCustomerAccountReport struct {
|
||||
Statements []*camtStatement `xml:"Rpt"`
|
||||
}
|
||||
|
||||
type camtBankToCustomerStatement struct {
|
||||
Statements []*camtStatement `xml:"Stmt"`
|
||||
}
|
||||
|
||||
@@ -10,11 +10,30 @@ import (
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
)
|
||||
|
||||
// camt052FileReader defines the structure of camt.052 file reader
|
||||
type camt052FileReader struct {
|
||||
xmlDecoder *xml.Decoder
|
||||
}
|
||||
|
||||
// camt053FileReader defines the structure of camt.053 file reader
|
||||
type camt053FileReader struct {
|
||||
xmlDecoder *xml.Decoder
|
||||
}
|
||||
|
||||
// read returns the imported camt.052 data
|
||||
// Reference: https://www.iso20022.org/message-set/1196/download
|
||||
func (r *camt052FileReader) read(ctx core.Context) (*camt052File, error) {
|
||||
file := &camt052File{}
|
||||
|
||||
err := r.xmlDecoder.Decode(&file)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return file, nil
|
||||
}
|
||||
|
||||
// read returns the imported camt.053 data
|
||||
// Reference: https://www.iso20022.org/message-set/1196/download
|
||||
func (r *camt053FileReader) read(ctx core.Context) (*camt053File, error) {
|
||||
@@ -29,6 +48,19 @@ func (r *camt053FileReader) read(ctx core.Context) (*camt053File, error) {
|
||||
return file, nil
|
||||
}
|
||||
|
||||
func createNewCamt052FileReader(data []byte) (*camt052FileReader, error) {
|
||||
if len(data) > 5 && data[0] == 0x3C && data[1] == 0x3F && data[2] == 0x78 && data[3] == 0x6D && data[4] == 0x6C { // <?xml
|
||||
xmlDecoder := xml.NewDecoder(bytes.NewReader(data))
|
||||
xmlDecoder.CharsetReader = charset.NewReaderLabel
|
||||
|
||||
return &camt052FileReader{
|
||||
xmlDecoder: xmlDecoder,
|
||||
}, nil
|
||||
}
|
||||
|
||||
return nil, errs.ErrInvalidXmlFile
|
||||
}
|
||||
|
||||
func createNewCamt053FileReader(data []byte) (*camt053FileReader, error) {
|
||||
if len(data) > 5 && data[0] == 0x3C && data[1] == 0x3F && data[2] == 0x78 && data[3] == 0x6D && data[4] == 0x6C { // <?xml
|
||||
xmlDecoder := xml.NewDecoder(bytes.NewReader(data))
|
||||
|
||||
@@ -303,12 +303,12 @@ func (t *camtStatementTransactionDataRowIterator) parseTransaction(ctx core.Cont
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func createNewCamtStatementTransactionDataTable(file *camt053File) (*camtStatementTransactionDataTable, error) {
|
||||
if file == nil || file.BankToCustomerStatement == nil || len(file.BankToCustomerStatement.Statements) == 0 {
|
||||
func createNewCamtStatementTransactionDataTable(camtStatements []*camtStatement) (*camtStatementTransactionDataTable, error) {
|
||||
if len(camtStatements) == 0 {
|
||||
return nil, errs.ErrNotFoundTransactionDataInFile
|
||||
}
|
||||
|
||||
return &camtStatementTransactionDataTable{
|
||||
allStatements: file.BankToCustomerStatement.Statements,
|
||||
allStatements: camtStatements,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/converters/converter"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
||||
)
|
||||
@@ -15,15 +16,49 @@ var camtTransactionTypeNameMapping = map[models.TransactionType]string{
|
||||
models.TRANSACTION_TYPE_TRANSFER: utils.IntToString(int(models.TRANSACTION_TYPE_TRANSFER)),
|
||||
}
|
||||
|
||||
// camt052TransactionDataImporter defines the structure of camt.052 file importer for transaction data
|
||||
type camt052TransactionDataImporter struct {
|
||||
}
|
||||
|
||||
// camt053TransactionDataImporter defines the structure of camt.053 file importer for transaction data
|
||||
type camt053TransactionDataImporter struct {
|
||||
}
|
||||
|
||||
// Initialize a camt.053 transaction data importer singleton instance
|
||||
// Initialize camt.052 and camt.053 transaction data importer singleton instances
|
||||
var (
|
||||
Camt052TransactionDataImporter = &camt052TransactionDataImporter{}
|
||||
Camt053TransactionDataImporter = &camt053TransactionDataImporter{}
|
||||
)
|
||||
|
||||
// ParseImportedData returns the imported data by parsing the camt.052 file transaction data
|
||||
func (c *camt052TransactionDataImporter) ParseImportedData(ctx core.Context, user *models.User, data []byte, defaultTimezone *time.Location, additionalOptions converter.TransactionDataImporterOptions, accountMap map[string]*models.Account, expenseCategoryMap map[string]map[string]*models.TransactionCategory, incomeCategoryMap map[string]map[string]*models.TransactionCategory, transferCategoryMap map[string]map[string]*models.TransactionCategory, tagMap map[string]*models.TransactionTag) (models.ImportedTransactionSlice, []*models.Account, []*models.TransactionCategory, []*models.TransactionCategory, []*models.TransactionCategory, []*models.TransactionTag, error) {
|
||||
camt052DataReader, err := createNewCamt052FileReader(data)
|
||||
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, nil, nil, err
|
||||
}
|
||||
|
||||
camt052Data, err := camt052DataReader.read(ctx)
|
||||
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, nil, nil, err
|
||||
}
|
||||
|
||||
if camt052Data.BankToCustomerAccountReport == nil || camt052Data.BankToCustomerAccountReport.Statements == nil {
|
||||
return nil, nil, nil, nil, nil, nil, errs.ErrNotFoundTransactionDataInFile
|
||||
}
|
||||
|
||||
transactionDataTable, err := createNewCamtStatementTransactionDataTable(camt052Data.BankToCustomerAccountReport.Statements)
|
||||
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, nil, nil, err
|
||||
}
|
||||
|
||||
dataTableImporter := converter.CreateNewSimpleImporterWithTypeNameMapping(camtTransactionTypeNameMapping)
|
||||
|
||||
return dataTableImporter.ParseImportedData(ctx, user, transactionDataTable, defaultTimezone, additionalOptions, accountMap, expenseCategoryMap, incomeCategoryMap, transferCategoryMap, tagMap)
|
||||
}
|
||||
|
||||
// ParseImportedData returns the imported data by parsing the camt.053 file transaction data
|
||||
func (c *camt053TransactionDataImporter) ParseImportedData(ctx core.Context, user *models.User, data []byte, defaultTimezone *time.Location, additionalOptions converter.TransactionDataImporterOptions, accountMap map[string]*models.Account, expenseCategoryMap map[string]map[string]*models.TransactionCategory, incomeCategoryMap map[string]map[string]*models.TransactionCategory, transferCategoryMap map[string]map[string]*models.TransactionCategory, tagMap map[string]*models.TransactionTag) (models.ImportedTransactionSlice, []*models.Account, []*models.TransactionCategory, []*models.TransactionCategory, []*models.TransactionCategory, []*models.TransactionTag, error) {
|
||||
camt053DataReader, err := createNewCamt053FileReader(data)
|
||||
@@ -38,7 +73,11 @@ func (c *camt053TransactionDataImporter) ParseImportedData(ctx core.Context, use
|
||||
return nil, nil, nil, nil, nil, nil, err
|
||||
}
|
||||
|
||||
transactionDataTable, err := createNewCamtStatementTransactionDataTable(camt053Data)
|
||||
if camt053Data.BankToCustomerStatement == nil || camt053Data.BankToCustomerStatement.Statements == nil {
|
||||
return nil, nil, nil, nil, nil, nil, errs.ErrNotFoundTransactionDataInFile
|
||||
}
|
||||
|
||||
transactionDataTable, err := createNewCamtStatementTransactionDataTable(camt053Data.BankToCustomerStatement.Statements)
|
||||
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, nil, nil, err
|
||||
|
||||
@@ -13,6 +13,109 @@ import (
|
||||
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
||||
)
|
||||
|
||||
func TestCamt052TransactionDataFileParseImportedData_MinimumValidData(t *testing.T) {
|
||||
importer := Camt052TransactionDataImporter
|
||||
context := core.NewNullContext()
|
||||
|
||||
user := &models.User{
|
||||
Uid: 1234567890,
|
||||
DefaultCurrency: "CNY",
|
||||
}
|
||||
|
||||
allNewTransactions, allNewAccounts, allNewSubExpenseCategories, allNewSubIncomeCategories, allNewSubTransferCategories, allNewTags, err := importer.ParseImportedData(context, user, []byte(
|
||||
`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Document xmlns="urn:iso:std:iso:20022:tech:xsd:camt.052.001.02">
|
||||
<BkToCstmrAcctRpt>
|
||||
<Rpt>
|
||||
<Acct>
|
||||
<Id>
|
||||
<IBAN>123</IBAN>
|
||||
</Id>
|
||||
<Ccy>CNY</Ccy>
|
||||
</Acct>
|
||||
<Ntry>
|
||||
<BookgDt>
|
||||
<DtTm>2024-09-01T01:23:45+08:00</DtTm>
|
||||
</BookgDt>
|
||||
<CdtDbtInd>CRDT</CdtDbtInd>
|
||||
<Amt Ccy="CNY">123.45</Amt>
|
||||
</Ntry>
|
||||
<Ntry>
|
||||
<BookgDt>
|
||||
<DtTm>2024-09-01T12:34:56+08:00</DtTm>
|
||||
</BookgDt>
|
||||
<CdtDbtInd>DBIT</CdtDbtInd>
|
||||
<Amt Ccy="CNY">0.12</Amt>
|
||||
</Ntry>
|
||||
</Rpt>
|
||||
<Rpt>
|
||||
<Acct>
|
||||
<Id>
|
||||
<Othr>
|
||||
<Id>456</Id>
|
||||
</Othr>
|
||||
</Id>
|
||||
<Ccy>USD</Ccy>
|
||||
</Acct>
|
||||
<Ntry>
|
||||
<BookgDt>
|
||||
<DtTm>2024-09-01T23:59:59+08:00</DtTm>
|
||||
</BookgDt>
|
||||
<CdtDbtInd>CRDT</CdtDbtInd>
|
||||
<Amt Ccy="USD">1.23</Amt>
|
||||
</Ntry>
|
||||
</Rpt>
|
||||
</BkToCstmrAcctRpt>
|
||||
</Document>`), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
|
||||
|
||||
assert.Nil(t, err)
|
||||
|
||||
assert.Equal(t, 3, len(allNewTransactions))
|
||||
assert.Equal(t, 2, len(allNewAccounts))
|
||||
assert.Equal(t, 1, len(allNewSubExpenseCategories))
|
||||
assert.Equal(t, 1, len(allNewSubIncomeCategories))
|
||||
assert.Equal(t, 0, len(allNewSubTransferCategories))
|
||||
assert.Equal(t, 0, len(allNewTags))
|
||||
|
||||
assert.Equal(t, int64(1234567890), allNewTransactions[0].Uid)
|
||||
assert.Equal(t, models.TRANSACTION_DB_TYPE_INCOME, allNewTransactions[0].Type)
|
||||
assert.Equal(t, int64(1725125025), utils.GetUnixTimeFromTransactionTime(allNewTransactions[0].TransactionTime))
|
||||
assert.Equal(t, int64(12345), allNewTransactions[0].Amount)
|
||||
assert.Equal(t, "123", allNewTransactions[0].OriginalSourceAccountName)
|
||||
assert.Equal(t, "CNY", allNewTransactions[0].OriginalSourceAccountCurrency)
|
||||
assert.Equal(t, "", allNewTransactions[0].OriginalCategoryName)
|
||||
|
||||
assert.Equal(t, int64(1234567890), allNewTransactions[1].Uid)
|
||||
assert.Equal(t, models.TRANSACTION_DB_TYPE_EXPENSE, allNewTransactions[1].Type)
|
||||
assert.Equal(t, int64(1725165296), utils.GetUnixTimeFromTransactionTime(allNewTransactions[1].TransactionTime))
|
||||
assert.Equal(t, int64(12), allNewTransactions[1].Amount)
|
||||
assert.Equal(t, "123", allNewTransactions[1].OriginalSourceAccountName)
|
||||
assert.Equal(t, "CNY", allNewTransactions[1].OriginalSourceAccountCurrency)
|
||||
assert.Equal(t, "", allNewTransactions[1].OriginalCategoryName)
|
||||
|
||||
assert.Equal(t, int64(1234567890), allNewTransactions[2].Uid)
|
||||
assert.Equal(t, models.TRANSACTION_DB_TYPE_INCOME, allNewTransactions[2].Type)
|
||||
assert.Equal(t, int64(1725206399), utils.GetUnixTimeFromTransactionTime(allNewTransactions[2].TransactionTime))
|
||||
assert.Equal(t, int64(123), allNewTransactions[2].Amount)
|
||||
assert.Equal(t, "456", allNewTransactions[2].OriginalSourceAccountName)
|
||||
assert.Equal(t, "USD", allNewTransactions[2].OriginalSourceAccountCurrency)
|
||||
assert.Equal(t, "", allNewTransactions[2].OriginalCategoryName)
|
||||
|
||||
assert.Equal(t, int64(1234567890), allNewAccounts[0].Uid)
|
||||
assert.Equal(t, "123", allNewAccounts[0].Name)
|
||||
assert.Equal(t, "CNY", allNewAccounts[0].Currency)
|
||||
|
||||
assert.Equal(t, int64(1234567890), allNewAccounts[1].Uid)
|
||||
assert.Equal(t, "456", allNewAccounts[1].Name)
|
||||
assert.Equal(t, "USD", allNewAccounts[1].Currency)
|
||||
|
||||
assert.Equal(t, int64(1234567890), allNewSubExpenseCategories[0].Uid)
|
||||
assert.Equal(t, "", allNewSubExpenseCategories[0].Name)
|
||||
|
||||
assert.Equal(t, int64(1234567890), allNewSubIncomeCategories[0].Uid)
|
||||
assert.Equal(t, "", allNewSubIncomeCategories[0].Name)
|
||||
}
|
||||
|
||||
func TestCamt053TransactionDataFileParseImportedData_MinimumValidData(t *testing.T) {
|
||||
importer := Camt053TransactionDataImporter
|
||||
context := core.NewNullContext()
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
package custom
|
||||
|
||||
import "github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
|
||||
// CustomTransactionDataParser represents the parser for custom transaction data files
|
||||
type CustomTransactionDataParser interface {
|
||||
ParseDataLines(ctx core.Context, data []byte) ([][]string, error)
|
||||
}
|
||||
+68
-63
@@ -1,4 +1,4 @@
|
||||
package dsv
|
||||
package custom
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"golang.org/x/text/encoding/simplifiedchinese"
|
||||
"golang.org/x/text/encoding/traditionalchinese"
|
||||
"golang.org/x/text/encoding/unicode"
|
||||
"golang.org/x/text/encoding/unicode/utf32"
|
||||
"golang.org/x/text/transform"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/converters/converter"
|
||||
@@ -29,61 +30,61 @@ import (
|
||||
var supportedFileTypeSeparators = map[string]rune{
|
||||
"custom_csv": ',',
|
||||
"custom_tsv": '\t',
|
||||
"custom_ssv": ';',
|
||||
}
|
||||
|
||||
var supportedFileEncodings = map[string]encoding.Encoding{
|
||||
"utf-8": unicode.UTF8, // UTF-8
|
||||
"utf-8-bom": unicode.UTF8BOM, // UTF-8 with BOM
|
||||
"utf-16le": unicode.UTF16(unicode.LittleEndian, unicode.UseBOM), // UTF-16 Little Endian
|
||||
"utf-16be": unicode.UTF16(unicode.BigEndian, unicode.UseBOM), // UTF-16 Big Endian
|
||||
"utf-16le-bom": unicode.UTF16(unicode.LittleEndian, unicode.ExpectBOM), // UTF-16 Little Endian with BOM
|
||||
"utf-16be-bom": unicode.UTF16(unicode.BigEndian, unicode.ExpectBOM), // UTF-16 Big Endian with BOM
|
||||
"cp437": charmap.CodePage437, // OEM United States (CP-437)
|
||||
"cp863": charmap.CodePage863, // OEM Canadian French (CP-863)
|
||||
"cp037": charmap.CodePage037, // IBM EBCDIC US/Canada (CP-037)
|
||||
"cp1047": charmap.CodePage1047, // IBM EBCDIC Open Systems (CP-1047)
|
||||
"cp1140": charmap.CodePage1140, // IBM EBCDIC US/Canada with Euro (CP-1140)
|
||||
"iso-8859-1": charmap.ISO8859_1, // Western European (ISO-8859-1)
|
||||
"cp850": charmap.CodePage850, // Western European (CP-850)
|
||||
"cp858": charmap.CodePage858, // Western European with Euro (CP-858)
|
||||
"windows-1252": charmap.Windows1252, // Western European (Windows-1252)
|
||||
"iso-8859-15": charmap.ISO8859_15, // Western European (ISO-8859-15)
|
||||
"iso-8859-4": charmap.ISO8859_4, // North European (ISO-8859-4)
|
||||
"iso-8859-10": charmap.ISO8859_10, // North European (ISO-8859-10)
|
||||
"cp865": charmap.CodePage865, // North European (CP-865)
|
||||
"iso-8859-2": charmap.ISO8859_2, // Central European (ISO-8859-2)
|
||||
"cp852": charmap.CodePage852, // Central European (CP-852)
|
||||
"windows-1250": charmap.Windows1250, // Central European (Windows-1250)
|
||||
"iso-8859-14": charmap.ISO8859_14, // Celtic (ISO-8859-14)
|
||||
"iso-8859-3": charmap.ISO8859_3, // South European (ISO-8859-3)
|
||||
"cp860": charmap.CodePage860, // Portuguese (CP-860)
|
||||
"iso-8859-7": charmap.ISO8859_7, // Greek (ISO-8859-7)
|
||||
"windows-1253": charmap.Windows1253, // Greek (Windows-1253)
|
||||
"iso-8859-9": charmap.ISO8859_9, // Turkish (ISO-8859-9)
|
||||
"windows-1254": charmap.Windows1254, // Turkish (Windows-1254)
|
||||
"iso-8859-13": charmap.ISO8859_13, // Baltic (ISO-8859-13)
|
||||
"windows-1257": charmap.Windows1257, // Baltic (Windows-1257)
|
||||
"iso-8859-16": charmap.ISO8859_16, // South-Eastern European (ISO-8859-16)
|
||||
"iso-8859-5": charmap.ISO8859_5, // Cyrillic (ISO-8859-5)
|
||||
"cp855": charmap.CodePage855, // Cyrillic (CP-855)
|
||||
"cp866": charmap.CodePage866, // Cyrillic (CP-866)
|
||||
"windows-1251": charmap.Windows1251, // Cyrillic (Windows-1251)
|
||||
"koi8r": charmap.KOI8R, // Cyrillic (KOI8-R)
|
||||
"koi8u": charmap.KOI8U, // Cyrillic (KOI8-U)
|
||||
"iso-8859-6": charmap.ISO8859_6, // Arabic (ISO-8859-6)
|
||||
"windows-1256": charmap.Windows1256, // Arabic (Windows-1256)
|
||||
"iso-8859-8": charmap.ISO8859_8, // Hebrew (ISO-8859-8)
|
||||
"cp862": charmap.CodePage862, // Hebrew (CP-862)
|
||||
"windows-1255": charmap.Windows1255, // Hebrew (Windows-1255)
|
||||
"windows-874": charmap.Windows874, // Thai (Windows-874)
|
||||
"windows-1258": charmap.Windows1258, // Vietnamese (Windows-1258)
|
||||
"gb18030": simplifiedchinese.GB18030, // Chinese (Simplified, GB18030)
|
||||
"gbk": simplifiedchinese.GBK, // Chinese (Simplified, GBK)
|
||||
"big5": traditionalchinese.Big5, // Chinese (Traditional, Big5)
|
||||
"euc-kr": korean.EUCKR, // Korean (EUC-KR)
|
||||
"euc-jp": japanese.EUCJP, // Japanese (EUC-JP)
|
||||
"iso-2022-jp": japanese.ISO2022JP, // Japanese (ISO-2022-JP)
|
||||
"shift_jis": japanese.ShiftJIS, // Japanese (Shift JIS)
|
||||
"utf-8": unicode.UTF8BOM, // UTF-8
|
||||
"utf-16le": unicode.UTF16(unicode.LittleEndian, unicode.UseBOM), // UTF-16 Little Endian
|
||||
"utf-16be": unicode.UTF16(unicode.BigEndian, unicode.UseBOM), // UTF-16 Big Endian
|
||||
"utf-32le": utf32.UTF32(utf32.LittleEndian, utf32.UseBOM), // UTF-32 Little Endian
|
||||
"utf-32be": utf32.UTF32(utf32.BigEndian, utf32.UseBOM), // UTF-32 Big Endian
|
||||
"cp437": charmap.CodePage437, // OEM United States (CP-437)
|
||||
"cp863": charmap.CodePage863, // OEM Canadian French (CP-863)
|
||||
"cp037": charmap.CodePage037, // IBM EBCDIC US/Canada (CP-037)
|
||||
"cp1047": charmap.CodePage1047, // IBM EBCDIC Open Systems (CP-1047)
|
||||
"cp1140": charmap.CodePage1140, // IBM EBCDIC US/Canada with Euro (CP-1140)
|
||||
"iso-8859-1": charmap.ISO8859_1, // Western European (ISO-8859-1)
|
||||
"cp850": charmap.CodePage850, // Western European (CP-850)
|
||||
"cp858": charmap.CodePage858, // Western European with Euro (CP-858)
|
||||
"windows-1252": charmap.Windows1252, // Western European (Windows-1252)
|
||||
"iso-8859-15": charmap.ISO8859_15, // Western European (ISO-8859-15)
|
||||
"iso-8859-4": charmap.ISO8859_4, // North European (ISO-8859-4)
|
||||
"iso-8859-10": charmap.ISO8859_10, // North European (ISO-8859-10)
|
||||
"cp865": charmap.CodePage865, // North European (CP-865)
|
||||
"iso-8859-2": charmap.ISO8859_2, // Central European (ISO-8859-2)
|
||||
"cp852": charmap.CodePage852, // Central European (CP-852)
|
||||
"windows-1250": charmap.Windows1250, // Central European (Windows-1250)
|
||||
"iso-8859-14": charmap.ISO8859_14, // Celtic (ISO-8859-14)
|
||||
"iso-8859-3": charmap.ISO8859_3, // South European (ISO-8859-3)
|
||||
"cp860": charmap.CodePage860, // Portuguese (CP-860)
|
||||
"iso-8859-7": charmap.ISO8859_7, // Greek (ISO-8859-7)
|
||||
"windows-1253": charmap.Windows1253, // Greek (Windows-1253)
|
||||
"iso-8859-9": charmap.ISO8859_9, // Turkish (ISO-8859-9)
|
||||
"windows-1254": charmap.Windows1254, // Turkish (Windows-1254)
|
||||
"iso-8859-13": charmap.ISO8859_13, // Baltic (ISO-8859-13)
|
||||
"windows-1257": charmap.Windows1257, // Baltic (Windows-1257)
|
||||
"iso-8859-16": charmap.ISO8859_16, // South-Eastern European (ISO-8859-16)
|
||||
"iso-8859-5": charmap.ISO8859_5, // Cyrillic (ISO-8859-5)
|
||||
"cp855": charmap.CodePage855, // Cyrillic (CP-855)
|
||||
"cp866": charmap.CodePage866, // Cyrillic (CP-866)
|
||||
"windows-1251": charmap.Windows1251, // Cyrillic (Windows-1251)
|
||||
"koi8r": charmap.KOI8R, // Cyrillic (KOI8-R)
|
||||
"koi8u": charmap.KOI8U, // Cyrillic (KOI8-U)
|
||||
"iso-8859-6": charmap.ISO8859_6, // Arabic (ISO-8859-6)
|
||||
"windows-1256": charmap.Windows1256, // Arabic (Windows-1256)
|
||||
"iso-8859-8": charmap.ISO8859_8, // Hebrew (ISO-8859-8)
|
||||
"cp862": charmap.CodePage862, // Hebrew (CP-862)
|
||||
"windows-1255": charmap.Windows1255, // Hebrew (Windows-1255)
|
||||
"windows-874": charmap.Windows874, // Thai (Windows-874)
|
||||
"windows-1258": charmap.Windows1258, // Vietnamese (Windows-1258)
|
||||
"gb18030": simplifiedchinese.GB18030, // Chinese (Simplified, GB18030)
|
||||
"gbk": simplifiedchinese.GBK, // Chinese (Simplified, GBK)
|
||||
"big5": traditionalchinese.Big5, // Chinese (Traditional, Big5)
|
||||
"euc-kr": korean.EUCKR, // Korean (EUC-KR)
|
||||
"euc-jp": japanese.EUCJP, // Japanese (EUC-JP)
|
||||
"iso-2022-jp": japanese.ISO2022JP, // Japanese (ISO-2022-JP)
|
||||
"shift_jis": japanese.ShiftJIS, // Japanese (Shift JIS)
|
||||
}
|
||||
|
||||
var customTransactionTypeNameMapping = map[models.TransactionType]string{
|
||||
@@ -93,10 +94,6 @@ var customTransactionTypeNameMapping = map[models.TransactionType]string{
|
||||
models.TRANSACTION_TYPE_TRANSFER: utils.IntToString(int(models.TRANSACTION_TYPE_TRANSFER)),
|
||||
}
|
||||
|
||||
type CustomTransactionDataDsvFileParser interface {
|
||||
ParseDsvFileLines(ctx core.Context, data []byte) ([][]string, error)
|
||||
}
|
||||
|
||||
// customTransactionDataDsvFileImporter defines the structure of custom dsv importer for transaction data
|
||||
type customTransactionDataDsvFileImporter struct {
|
||||
fileEncoding encoding.Encoding
|
||||
@@ -113,8 +110,8 @@ type customTransactionDataDsvFileImporter struct {
|
||||
transactionTagSeparator string
|
||||
}
|
||||
|
||||
// ParseDsvFileLines returns the parsed file lines for specified the dsv file data
|
||||
func (c *customTransactionDataDsvFileImporter) ParseDsvFileLines(ctx core.Context, data []byte) ([][]string, error) {
|
||||
// ParseDataLines returns the parsed file lines for specified the dsv file data
|
||||
func (c *customTransactionDataDsvFileImporter) ParseDataLines(ctx core.Context, data []byte) ([][]string, error) {
|
||||
reader := transform.NewReader(bytes.NewReader(data), c.fileEncoding.NewDecoder())
|
||||
csvReader := csv.NewReader(reader)
|
||||
csvReader.Comma = c.separator
|
||||
@@ -130,7 +127,7 @@ func (c *customTransactionDataDsvFileImporter) ParseDsvFileLines(ctx core.Contex
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(ctx, "[custom_transaction_data_dsv_file_importer.ParseDsvFileLines] cannot parse dsv data, because %s", err.Error())
|
||||
log.Errorf(ctx, "[custom_transaction_data_dsv_file_importer.ParseDataLines] cannot parse dsv data, because %s", err.Error())
|
||||
return nil, errs.ErrInvalidCSVFile
|
||||
}
|
||||
|
||||
@@ -150,7 +147,7 @@ func (c *customTransactionDataDsvFileImporter) ParseDsvFileLines(ctx core.Contex
|
||||
|
||||
// ParseImportedData returns the imported data by parsing the custom transaction dsv data
|
||||
func (c *customTransactionDataDsvFileImporter) ParseImportedData(ctx core.Context, user *models.User, data []byte, defaultTimezone *time.Location, additionalOptions converter.TransactionDataImporterOptions, accountMap map[string]*models.Account, expenseCategoryMap map[string]map[string]*models.TransactionCategory, incomeCategoryMap map[string]map[string]*models.TransactionCategory, transferCategoryMap map[string]map[string]*models.TransactionCategory, tagMap map[string]*models.TransactionTag) (models.ImportedTransactionSlice, []*models.Account, []*models.TransactionCategory, []*models.TransactionCategory, []*models.TransactionCategory, []*models.TransactionTag, error) {
|
||||
allLines, err := c.ParseDsvFileLines(ctx, data)
|
||||
allLines, err := c.ParseDataLines(ctx, data)
|
||||
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, nil, nil, err
|
||||
@@ -169,14 +166,18 @@ func IsDelimiterSeparatedValuesFileType(fileType string) bool {
|
||||
return exists
|
||||
}
|
||||
|
||||
// CreateNewCustomTransactionDataDsvFileParser returns a new custom dsv parser for transaction data
|
||||
func CreateNewCustomTransactionDataDsvFileParser(fileType string, fileEncoding string) (CustomTransactionDataDsvFileParser, error) {
|
||||
// CreateNewCustomTransactionDataDsvFileParser returns a new custom transaction data parser
|
||||
func CreateNewCustomTransactionDataDsvFileParser(fileType string, fileEncoding string) (CustomTransactionDataParser, error) {
|
||||
separator, exists := supportedFileTypeSeparators[fileType]
|
||||
|
||||
if !exists {
|
||||
return nil, errs.ErrImportFileTypeNotSupported
|
||||
}
|
||||
|
||||
if fileEncoding == "" {
|
||||
return nil, errs.ErrImportFileEncodingIsEmpty
|
||||
}
|
||||
|
||||
enc, exists := supportedFileEncodings[fileEncoding]
|
||||
|
||||
if !exists {
|
||||
@@ -197,6 +198,10 @@ func CreateNewCustomTransactionDataDsvFileImporter(fileType string, fileEncoding
|
||||
return nil, errs.ErrImportFileTypeNotSupported
|
||||
}
|
||||
|
||||
if fileEncoding == "" {
|
||||
return nil, errs.ErrImportFileEncodingIsEmpty
|
||||
}
|
||||
|
||||
enc, exists := supportedFileEncodings[fileEncoding]
|
||||
|
||||
if !exists {
|
||||
+26
-4
@@ -1,4 +1,4 @@
|
||||
package dsv
|
||||
package custom
|
||||
|
||||
import (
|
||||
"testing"
|
||||
@@ -17,19 +17,21 @@ import (
|
||||
func TestIsDelimiterSeparatedValuesFileType(t *testing.T) {
|
||||
assert.True(t, IsDelimiterSeparatedValuesFileType("custom_csv"))
|
||||
assert.True(t, IsDelimiterSeparatedValuesFileType("custom_tsv"))
|
||||
assert.True(t, IsDelimiterSeparatedValuesFileType("custom_ssv"))
|
||||
|
||||
assert.False(t, IsDelimiterSeparatedValuesFileType("dsv"))
|
||||
assert.False(t, IsDelimiterSeparatedValuesFileType("csv"))
|
||||
assert.False(t, IsDelimiterSeparatedValuesFileType("tsv"))
|
||||
assert.False(t, IsDelimiterSeparatedValuesFileType("ssv"))
|
||||
}
|
||||
|
||||
func TestCustomTransactionDataDsvFileParser_ParseDsvFileLines(t *testing.T) {
|
||||
func TestCustomTransactionDataDsvFileParser_ParseDataLines(t *testing.T) {
|
||||
importer, err := CreateNewCustomTransactionDataDsvFileParser("custom_csv", "utf-8")
|
||||
assert.Nil(t, err)
|
||||
|
||||
context := core.NewNullContext()
|
||||
|
||||
allLines, err := importer.ParseDsvFileLines(context, []byte(
|
||||
allLines, err := importer.ParseDataLines(context, []byte(
|
||||
"2024-09-01 00:00:00,B,123.45\n"+
|
||||
"2024-09-01 01:23:45,I,0.12\n"))
|
||||
assert.Nil(t, err)
|
||||
@@ -49,7 +51,7 @@ func TestCustomTransactionDataDsvFileParser_ParseDsvFileLines(t *testing.T) {
|
||||
importer, err = CreateNewCustomTransactionDataDsvFileParser("custom_tsv", "utf-8")
|
||||
assert.Nil(t, err)
|
||||
|
||||
allLines, err = importer.ParseDsvFileLines(context, []byte(
|
||||
allLines, err = importer.ParseDataLines(context, []byte(
|
||||
"2024-09-01 12:34:56\tE\t1.00\n"+
|
||||
"2024-09-01 23:59:59\tT\t0.05"))
|
||||
assert.Nil(t, err)
|
||||
@@ -65,6 +67,26 @@ func TestCustomTransactionDataDsvFileParser_ParseDsvFileLines(t *testing.T) {
|
||||
assert.Equal(t, "2024-09-01 23:59:59", allLines[1][0])
|
||||
assert.Equal(t, "T", allLines[1][1])
|
||||
assert.Equal(t, "0.05", allLines[1][2])
|
||||
|
||||
importer, err = CreateNewCustomTransactionDataDsvFileParser("custom_ssv", "utf-8")
|
||||
assert.Nil(t, err)
|
||||
|
||||
allLines, err = importer.ParseDataLines(context, []byte(
|
||||
"2024-09-01 12:34:56;E;1.00\n"+
|
||||
"2024-09-01 23:59:59;T;0.05"))
|
||||
assert.Nil(t, err)
|
||||
|
||||
assert.Equal(t, 2, len(allLines))
|
||||
|
||||
assert.Equal(t, 3, len(allLines[0]))
|
||||
assert.Equal(t, "2024-09-01 12:34:56", allLines[0][0])
|
||||
assert.Equal(t, "E", allLines[0][1])
|
||||
assert.Equal(t, "1.00", allLines[0][2])
|
||||
|
||||
assert.Equal(t, 3, len(allLines[1]))
|
||||
assert.Equal(t, "2024-09-01 23:59:59", allLines[1][0])
|
||||
assert.Equal(t, "T", allLines[1][1])
|
||||
assert.Equal(t, "0.05", allLines[1][2])
|
||||
}
|
||||
|
||||
func TestCustomTransactionDataDsvFileImporter_MinimumValidData(t *testing.T) {
|
||||
@@ -0,0 +1,137 @@
|
||||
package custom
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/converters/converter"
|
||||
csvconverter "github.com/mayswind/ezbookkeeping/pkg/converters/csv"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/converters/datatable"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/converters/excel"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||
)
|
||||
|
||||
const customOOXMLExcelFileType = "custom_xlsx"
|
||||
const customMSCFBExcelFileType = "custom_xls"
|
||||
|
||||
// customTransactionDataExcelFileImporter defines the structure of custom excel importer for transaction data
|
||||
type customTransactionDataExcelFileImporter struct {
|
||||
fileType string
|
||||
columnIndexMapping map[datatable.TransactionDataTableColumn]int
|
||||
transactionTypeNameMapping map[string]models.TransactionType
|
||||
hasHeaderLine bool
|
||||
timeFormat string
|
||||
timezoneFormat string
|
||||
amountDecimalSeparator string
|
||||
amountDigitGroupingSymbol string
|
||||
geoLocationSeparator string
|
||||
geoLocationOrder converter.TransactionGeoLocationOrder
|
||||
transactionTagSeparator string
|
||||
}
|
||||
|
||||
// ParseDataLines returns the parsed file lines for specified the excel file data
|
||||
func (c *customTransactionDataExcelFileImporter) ParseDataLines(ctx core.Context, data []byte) ([][]string, error) {
|
||||
var excelDataTable datatable.BasicDataTable
|
||||
var err error
|
||||
|
||||
if c.fileType == customOOXMLExcelFileType {
|
||||
excelDataTable, err = excel.CreateNewExcelOOXMLFileBasicDataTable(data, false)
|
||||
} else if c.fileType == customMSCFBExcelFileType {
|
||||
excelDataTable, err = excel.CreateNewExcelMSCFBFileBasicDataTable(data, false)
|
||||
} else {
|
||||
return nil, errs.ErrImportFileTypeNotSupported
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
iterator := excelDataTable.DataRowIterator()
|
||||
allLines := make([][]string, 0)
|
||||
|
||||
for iterator.HasNext() {
|
||||
row := iterator.Next()
|
||||
items := make([]string, row.ColumnCount())
|
||||
|
||||
for i := 0; i < row.ColumnCount(); i++ {
|
||||
items[i] = strings.Trim(row.GetData(i), " ")
|
||||
}
|
||||
|
||||
allLines = append(allLines, items)
|
||||
}
|
||||
|
||||
return allLines, nil
|
||||
}
|
||||
|
||||
// ParseImportedData returns the imported data by parsing the custom transaction dsv data
|
||||
func (c *customTransactionDataExcelFileImporter) ParseImportedData(ctx core.Context, user *models.User, data []byte, defaultTimezone *time.Location, additionalOptions converter.TransactionDataImporterOptions, accountMap map[string]*models.Account, expenseCategoryMap map[string]map[string]*models.TransactionCategory, incomeCategoryMap map[string]map[string]*models.TransactionCategory, transferCategoryMap map[string]map[string]*models.TransactionCategory, tagMap map[string]*models.TransactionTag) (models.ImportedTransactionSlice, []*models.Account, []*models.TransactionCategory, []*models.TransactionCategory, []*models.TransactionCategory, []*models.TransactionTag, error) {
|
||||
allLines, err := c.ParseDataLines(ctx, data)
|
||||
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, nil, nil, err
|
||||
}
|
||||
|
||||
dataTable := csvconverter.CreateNewCustomCsvBasicDataTable(allLines, c.hasHeaderLine)
|
||||
transactionDataTable := CreateNewCustomPlainTextDataTable(dataTable, c.columnIndexMapping, c.transactionTypeNameMapping, c.timeFormat, c.timezoneFormat, c.amountDecimalSeparator, c.amountDigitGroupingSymbol)
|
||||
dataTableImporter := converter.CreateNewImporterWithTypeNameMapping(customTransactionTypeNameMapping, c.geoLocationSeparator, c.geoLocationOrder, c.transactionTagSeparator)
|
||||
|
||||
return dataTableImporter.ParseImportedData(ctx, user, transactionDataTable, defaultTimezone, additionalOptions, accountMap, expenseCategoryMap, incomeCategoryMap, transferCategoryMap, tagMap)
|
||||
}
|
||||
|
||||
// IsCustomExcelFileType returns whether the file type is the custom excel file type
|
||||
func IsCustomExcelFileType(fileType string) bool {
|
||||
return fileType == customOOXMLExcelFileType || fileType == customMSCFBExcelFileType
|
||||
}
|
||||
|
||||
// CreateNewCustomTransactionDataExcelFileParser returns a new custom transaction data parser
|
||||
func CreateNewCustomTransactionDataExcelFileParser(fileType string) (CustomTransactionDataParser, error) {
|
||||
if fileType != customOOXMLExcelFileType && fileType != customMSCFBExcelFileType {
|
||||
return nil, errs.ErrImportFileTypeNotSupported
|
||||
}
|
||||
|
||||
return &customTransactionDataExcelFileImporter{
|
||||
fileType: fileType,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CreateNewCustomTransactionDataExcelFileImporter returns a new custom excel importer for transaction data
|
||||
func CreateNewCustomTransactionDataExcelFileImporter(fileType string, columnIndexMapping map[datatable.TransactionDataTableColumn]int, transactionTypeNameMapping map[string]models.TransactionType, hasHeaderLine bool, timeFormat string, timezoneFormat string, amountDecimalSeparator string, amountDigitGroupingSymbol string, geoLocationSeparator string, geoLocationOrder string, transactionTagSeparator string) (converter.TransactionDataImporter, error) {
|
||||
if fileType != customOOXMLExcelFileType && fileType != customMSCFBExcelFileType {
|
||||
return nil, errs.ErrImportFileTypeNotSupported
|
||||
}
|
||||
|
||||
if geoLocationOrder == "" {
|
||||
geoLocationOrder = string(converter.TRANSACTION_GEO_LOCATION_ORDER_LONGITUDE_LATITUDE)
|
||||
} else if geoLocationOrder != string(converter.TRANSACTION_GEO_LOCATION_ORDER_LONGITUDE_LATITUDE) &&
|
||||
geoLocationOrder != string(converter.TRANSACTION_GEO_LOCATION_ORDER_LATITUDE_LONGITUDE) {
|
||||
return nil, errs.ErrImportFileTypeNotSupported
|
||||
}
|
||||
|
||||
if _, exists := columnIndexMapping[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME]; !exists {
|
||||
return nil, errs.ErrMissingRequiredFieldInHeaderRow
|
||||
}
|
||||
|
||||
if _, exists := columnIndexMapping[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE]; !exists {
|
||||
return nil, errs.ErrMissingRequiredFieldInHeaderRow
|
||||
}
|
||||
|
||||
if _, exists := columnIndexMapping[datatable.TRANSACTION_DATA_TABLE_AMOUNT]; !exists {
|
||||
return nil, errs.ErrMissingRequiredFieldInHeaderRow
|
||||
}
|
||||
|
||||
return &customTransactionDataExcelFileImporter{
|
||||
fileType: fileType,
|
||||
columnIndexMapping: columnIndexMapping,
|
||||
transactionTypeNameMapping: transactionTypeNameMapping,
|
||||
hasHeaderLine: hasHeaderLine,
|
||||
timeFormat: timeFormat,
|
||||
timezoneFormat: timezoneFormat,
|
||||
amountDecimalSeparator: amountDecimalSeparator,
|
||||
amountDigitGroupingSymbol: amountDigitGroupingSymbol,
|
||||
geoLocationSeparator: geoLocationSeparator,
|
||||
geoLocationOrder: converter.TransactionGeoLocationOrder(geoLocationOrder),
|
||||
transactionTagSeparator: transactionTagSeparator,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
package custom
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestIsCustomExcelFileType(t *testing.T) {
|
||||
assert.True(t, IsCustomExcelFileType("custom_xlsx"))
|
||||
assert.True(t, IsCustomExcelFileType("custom_xls"))
|
||||
|
||||
assert.False(t, IsCustomExcelFileType("xlsx"))
|
||||
assert.False(t, IsCustomExcelFileType("xls"))
|
||||
assert.False(t, IsCustomExcelFileType("excel"))
|
||||
}
|
||||
|
||||
func TestCustomTransactionDataParser_ParseOOXMLExcelDataLines_EmptyData(t *testing.T) {
|
||||
importer, err := CreateNewCustomTransactionDataExcelFileParser("custom_xlsx")
|
||||
assert.Nil(t, err)
|
||||
|
||||
context := core.NewNullContext()
|
||||
|
||||
testdata, err := os.ReadFile("../../../testdata/empty_excel_file.xlsx")
|
||||
assert.Nil(t, err)
|
||||
|
||||
allLines, err := importer.ParseDataLines(context, testdata)
|
||||
assert.Nil(t, err)
|
||||
|
||||
assert.Equal(t, 0, len(allLines))
|
||||
}
|
||||
|
||||
func TestCustomTransactionDataParser_ParseOOXMLExcelDataLines_SingleSheet(t *testing.T) {
|
||||
importer, err := CreateNewCustomTransactionDataExcelFileParser("custom_xlsx")
|
||||
assert.Nil(t, err)
|
||||
|
||||
context := core.NewNullContext()
|
||||
|
||||
testdata, err := os.ReadFile("../../../testdata/simple_excel_file.xlsx")
|
||||
assert.Nil(t, err)
|
||||
|
||||
allLines, err := importer.ParseDataLines(context, testdata)
|
||||
assert.Nil(t, err)
|
||||
|
||||
assert.Equal(t, 3, len(allLines))
|
||||
|
||||
assert.Equal(t, 3, len(allLines[0]))
|
||||
assert.Equal(t, "A1", allLines[0][0])
|
||||
assert.Equal(t, "B1", allLines[0][1])
|
||||
assert.Equal(t, "C1", allLines[0][2])
|
||||
|
||||
assert.Equal(t, 3, len(allLines[1]))
|
||||
assert.Equal(t, "A2", allLines[1][0])
|
||||
assert.Equal(t, "B2", allLines[1][1])
|
||||
assert.Equal(t, "C2", allLines[1][2])
|
||||
|
||||
assert.Equal(t, 3, len(allLines[2]))
|
||||
assert.Equal(t, "A3", allLines[2][0])
|
||||
assert.Equal(t, "B3", allLines[2][1])
|
||||
assert.Equal(t, "C3", allLines[2][2])
|
||||
}
|
||||
|
||||
func TestCustomTransactionDataParser_ParseOOXMLExcelDataLines_MultipleSheet(t *testing.T) {
|
||||
importer, err := CreateNewCustomTransactionDataExcelFileParser("custom_xlsx")
|
||||
assert.Nil(t, err)
|
||||
|
||||
context := core.NewNullContext()
|
||||
|
||||
testdata, err := os.ReadFile("../../../testdata/multiple_sheets_excel_file.xlsx")
|
||||
assert.Nil(t, err)
|
||||
|
||||
allLines, err := importer.ParseDataLines(context, testdata)
|
||||
assert.Nil(t, err)
|
||||
|
||||
assert.Equal(t, 9, len(allLines))
|
||||
|
||||
assert.Equal(t, 3, len(allLines[0]))
|
||||
assert.Equal(t, "A1", allLines[0][0])
|
||||
assert.Equal(t, "B1", allLines[0][1])
|
||||
assert.Equal(t, "C1", allLines[0][2])
|
||||
|
||||
assert.Equal(t, 3, len(allLines[1]))
|
||||
assert.Equal(t, "1-A2", allLines[1][0])
|
||||
assert.Equal(t, "1-B2", allLines[1][1])
|
||||
assert.Equal(t, "1-C2", allLines[1][2])
|
||||
|
||||
assert.Equal(t, 3, len(allLines[2]))
|
||||
assert.Equal(t, "1-A3", allLines[2][0])
|
||||
assert.Equal(t, "1-B3", allLines[2][1])
|
||||
assert.Equal(t, "1-C3", allLines[2][2])
|
||||
|
||||
assert.Equal(t, 3, len(allLines[3]))
|
||||
assert.Equal(t, "A1", allLines[3][0])
|
||||
assert.Equal(t, "B1", allLines[3][1])
|
||||
assert.Equal(t, "C1", allLines[3][2])
|
||||
|
||||
assert.Equal(t, 2, len(allLines[4]))
|
||||
assert.Equal(t, "3-A2", allLines[4][0])
|
||||
assert.Equal(t, "3-B2", allLines[4][1])
|
||||
|
||||
assert.Equal(t, 3, len(allLines[5]))
|
||||
assert.Equal(t, "A1", allLines[5][0])
|
||||
assert.Equal(t, "B1", allLines[5][1])
|
||||
assert.Equal(t, "C1", allLines[5][2])
|
||||
|
||||
assert.Equal(t, 3, len(allLines[6]))
|
||||
assert.Equal(t, "A1", allLines[6][0])
|
||||
assert.Equal(t, "B1", allLines[6][1])
|
||||
assert.Equal(t, "C1", allLines[6][2])
|
||||
|
||||
assert.Equal(t, 3, len(allLines[7]))
|
||||
assert.Equal(t, "5-A2", allLines[7][0])
|
||||
assert.Equal(t, "5-B2", allLines[7][1])
|
||||
assert.Equal(t, "5-C2", allLines[7][2])
|
||||
|
||||
assert.Equal(t, 3, len(allLines[8]))
|
||||
assert.Equal(t, "5-A3", allLines[8][0])
|
||||
assert.Equal(t, "5-B3", allLines[8][1])
|
||||
assert.Equal(t, "5-C3", allLines[8][2])
|
||||
}
|
||||
|
||||
func TestCustomTransactionDataParser_ParseOOXMLExcelDataLines_MultipleSheetWithDifferentColumnCount(t *testing.T) {
|
||||
importer, err := CreateNewCustomTransactionDataExcelFileParser("custom_xlsx")
|
||||
assert.Nil(t, err)
|
||||
|
||||
context := core.NewNullContext()
|
||||
|
||||
testdata, err := os.ReadFile("../../../testdata/multiple_sheets_with_different_header_row_excel_file.xlsx")
|
||||
assert.Nil(t, err)
|
||||
|
||||
_, err = importer.ParseDataLines(context, testdata)
|
||||
assert.EqualError(t, err, errs.ErrFieldsInMultiTableAreDifferent.Message)
|
||||
}
|
||||
|
||||
func TestCustomTransactionDataParser_ParseMSCFBExcelDataLines_EmptyData(t *testing.T) {
|
||||
importer, err := CreateNewCustomTransactionDataExcelFileParser("custom_xls")
|
||||
assert.Nil(t, err)
|
||||
|
||||
context := core.NewNullContext()
|
||||
|
||||
testdata, err := os.ReadFile("../../../testdata/empty_excel_file.xls")
|
||||
assert.Nil(t, err)
|
||||
|
||||
allLines, err := importer.ParseDataLines(context, testdata)
|
||||
assert.Nil(t, err)
|
||||
|
||||
assert.Equal(t, 0, len(allLines))
|
||||
}
|
||||
|
||||
func TestCustomTransactionDataParser_ParseMSCFBExcelDataLines_SingleSheet(t *testing.T) {
|
||||
importer, err := CreateNewCustomTransactionDataExcelFileParser("custom_xls")
|
||||
assert.Nil(t, err)
|
||||
|
||||
context := core.NewNullContext()
|
||||
|
||||
testdata, err := os.ReadFile("../../../testdata/simple_excel_file.xls")
|
||||
assert.Nil(t, err)
|
||||
|
||||
allLines, err := importer.ParseDataLines(context, testdata)
|
||||
assert.Nil(t, err)
|
||||
|
||||
assert.Equal(t, 3, len(allLines))
|
||||
|
||||
assert.Equal(t, 3, len(allLines[0]))
|
||||
assert.Equal(t, "A1", allLines[0][0])
|
||||
assert.Equal(t, "B1", allLines[0][1])
|
||||
assert.Equal(t, "C1", allLines[0][2])
|
||||
|
||||
assert.Equal(t, 3, len(allLines[1]))
|
||||
assert.Equal(t, "A2", allLines[1][0])
|
||||
assert.Equal(t, "B2", allLines[1][1])
|
||||
assert.Equal(t, "C2", allLines[1][2])
|
||||
|
||||
assert.Equal(t, 3, len(allLines[2]))
|
||||
assert.Equal(t, "A3", allLines[2][0])
|
||||
assert.Equal(t, "B3", allLines[2][1])
|
||||
assert.Equal(t, "C3", allLines[2][2])
|
||||
}
|
||||
|
||||
func TestCustomTransactionDataParser_ParseMSCFBExcelDataLines_MultipleSheet(t *testing.T) {
|
||||
importer, err := CreateNewCustomTransactionDataExcelFileParser("custom_xls")
|
||||
assert.Nil(t, err)
|
||||
|
||||
context := core.NewNullContext()
|
||||
|
||||
testdata, err := os.ReadFile("../../../testdata/multiple_sheets_excel_file.xls")
|
||||
assert.Nil(t, err)
|
||||
|
||||
allLines, err := importer.ParseDataLines(context, testdata)
|
||||
assert.Nil(t, err)
|
||||
|
||||
assert.Equal(t, 9, len(allLines))
|
||||
|
||||
assert.Equal(t, 3, len(allLines[0]))
|
||||
assert.Equal(t, "A1", allLines[0][0])
|
||||
assert.Equal(t, "B1", allLines[0][1])
|
||||
assert.Equal(t, "C1", allLines[0][2])
|
||||
|
||||
assert.Equal(t, 3, len(allLines[1]))
|
||||
assert.Equal(t, "1-A2", allLines[1][0])
|
||||
assert.Equal(t, "1-B2", allLines[1][1])
|
||||
assert.Equal(t, "1-C2", allLines[1][2])
|
||||
|
||||
assert.Equal(t, 3, len(allLines[2]))
|
||||
assert.Equal(t, "1-A3", allLines[2][0])
|
||||
assert.Equal(t, "1-B3", allLines[2][1])
|
||||
assert.Equal(t, "1-C3", allLines[2][2])
|
||||
|
||||
assert.Equal(t, 3, len(allLines[3]))
|
||||
assert.Equal(t, "A1", allLines[3][0])
|
||||
assert.Equal(t, "B1", allLines[3][1])
|
||||
assert.Equal(t, "C1", allLines[3][2])
|
||||
|
||||
assert.Equal(t, 3, len(allLines[4]))
|
||||
assert.Equal(t, "3-A2", allLines[4][0])
|
||||
assert.Equal(t, "3-B2", allLines[4][1])
|
||||
assert.Equal(t, "", allLines[4][2])
|
||||
|
||||
assert.Equal(t, 3, len(allLines[5]))
|
||||
assert.Equal(t, "A1", allLines[5][0])
|
||||
assert.Equal(t, "B1", allLines[5][1])
|
||||
assert.Equal(t, "C1", allLines[5][2])
|
||||
|
||||
assert.Equal(t, 3, len(allLines[6]))
|
||||
assert.Equal(t, "A1", allLines[6][0])
|
||||
assert.Equal(t, "B1", allLines[6][1])
|
||||
assert.Equal(t, "C1", allLines[6][2])
|
||||
|
||||
assert.Equal(t, 3, len(allLines[7]))
|
||||
assert.Equal(t, "5-A2", allLines[7][0])
|
||||
assert.Equal(t, "5-B2", allLines[7][1])
|
||||
assert.Equal(t, "5-C2", allLines[7][2])
|
||||
|
||||
assert.Equal(t, 3, len(allLines[8]))
|
||||
assert.Equal(t, "5-A3", allLines[8][0])
|
||||
assert.Equal(t, "5-B3", allLines[8][1])
|
||||
assert.Equal(t, "5-C3", allLines[8][2])
|
||||
}
|
||||
|
||||
func TestCustomTransactionDataParser_ParseMSCFBExcelDataLines_MultipleSheetWithDifferentColumnCount(t *testing.T) {
|
||||
importer, err := CreateNewCustomTransactionDataExcelFileParser("custom_xls")
|
||||
assert.Nil(t, err)
|
||||
|
||||
context := core.NewNullContext()
|
||||
|
||||
testdata, err := os.ReadFile("../../../testdata/multiple_sheets_with_different_header_row_excel_file.xls")
|
||||
assert.Nil(t, err)
|
||||
|
||||
_, err = importer.ParseDataLines(context, testdata)
|
||||
assert.EqualError(t, err, errs.ErrFieldsInMultiTableAreDifferent.Message)
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package dsv
|
||||
package custom
|
||||
|
||||
import (
|
||||
"strings"
|
||||
@@ -86,7 +86,7 @@ func (t *ExcelMSCFBFileBasicDataTable) DataRowIterator() datatable.BasicDataTabl
|
||||
// ColumnCount returns the total count of column in this data row
|
||||
func (r *ExcelMSCFBFileBasicDataTableRow) ColumnCount() int {
|
||||
row := r.sheet.Row(r.rowIndex)
|
||||
return row.LastCol() + 1
|
||||
return row.LastCol()
|
||||
}
|
||||
|
||||
// GetData returns the data in the specified column index
|
||||
@@ -195,7 +195,10 @@ func CreateNewExcelMSCFBFileBasicDataTable(data []byte, hasTitleLine bool) (data
|
||||
}
|
||||
|
||||
if i == 0 {
|
||||
for j := 0; j <= row.LastCol(); j++ {
|
||||
// row.LastCol() returns "colMac" in the "Row" struct, that is an unsigned integer that specifies the one-based index of the last column.
|
||||
// But row.FirstCol() returns "colMic" in the "Row" struct, that is an unsigned integer that specifies the zero-based index of the first column.
|
||||
// Reference: https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-xls/4aab09eb-49ed-4d01-a3b1-1d726247d3c2
|
||||
for j := 0; j < row.LastCol(); j++ {
|
||||
headerItem := row.Col(j)
|
||||
|
||||
if headerItem == "" {
|
||||
@@ -205,7 +208,7 @@ func CreateNewExcelMSCFBFileBasicDataTable(data []byte, hasTitleLine bool) (data
|
||||
firstRowItems = append(firstRowItems, headerItem)
|
||||
}
|
||||
} else {
|
||||
for j := 0; j <= min(row.LastCol(), len(firstRowItems)-1); j++ {
|
||||
for j := 0; j < min(row.LastCol(), len(firstRowItems)); j++ {
|
||||
headerItem := row.Col(j)
|
||||
|
||||
if headerItem != firstRowItems[j] {
|
||||
|
||||
@@ -300,10 +300,10 @@ func TestExcelMSCFBFileBasicDataRowColumnCount(t *testing.T) {
|
||||
iterator := datatable.DataRowIterator()
|
||||
|
||||
row1 := iterator.Next()
|
||||
assert.EqualValues(t, 4, row1.ColumnCount())
|
||||
assert.EqualValues(t, 3, row1.ColumnCount())
|
||||
|
||||
row2 := iterator.Next()
|
||||
assert.EqualValues(t, 4, row2.ColumnCount())
|
||||
assert.EqualValues(t, 3, row2.ColumnCount())
|
||||
}
|
||||
|
||||
func TestExcelMSCFBFileBasicDataRowGetData(t *testing.T) {
|
||||
|
||||
@@ -5,9 +5,9 @@ import (
|
||||
"github.com/mayswind/ezbookkeeping/pkg/converters/beancount"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/converters/camt"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/converters/converter"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/converters/custom"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/converters/datatable"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/converters/default"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/converters/dsv"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/converters/feidee"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/converters/fireflyIII"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/converters/gnucash"
|
||||
@@ -52,6 +52,8 @@ func GetTransactionDataImporter(fileType string) (converter.TransactionDataImpor
|
||||
return qif.QifDayMonthYearTransactionDataImporter, nil
|
||||
} else if fileType == "iif" {
|
||||
return iif.IifTransactionDataFileImporter, nil
|
||||
} else if fileType == "camt052" {
|
||||
return camt.Camt052TransactionDataImporter, nil
|
||||
} else if fileType == "camt053" {
|
||||
return camt.Camt053TransactionDataImporter, nil
|
||||
} else if fileType == "mt940" {
|
||||
@@ -83,17 +85,29 @@ func GetTransactionDataImporter(fileType string) (converter.TransactionDataImpor
|
||||
}
|
||||
}
|
||||
|
||||
// IsCustomDelimiterSeparatedValuesFileType returns whether the file type is the delimiter-separated values file type
|
||||
func IsCustomDelimiterSeparatedValuesFileType(fileType string) bool {
|
||||
return dsv.IsDelimiterSeparatedValuesFileType(fileType)
|
||||
// IsCustomFileFormatFileType returns whether the file type is the custom file format
|
||||
func IsCustomFileFormatFileType(fileType string) bool {
|
||||
return custom.IsDelimiterSeparatedValuesFileType(fileType) || custom.IsCustomExcelFileType(fileType)
|
||||
}
|
||||
|
||||
// CreateNewDelimiterSeparatedValuesDataParser returns a new delimiter-separated values data parser according to the file type and encoding
|
||||
func CreateNewDelimiterSeparatedValuesDataParser(fileType string, fileEncoding string) (dsv.CustomTransactionDataDsvFileParser, error) {
|
||||
return dsv.CreateNewCustomTransactionDataDsvFileParser(fileType, fileEncoding)
|
||||
// CreateNewCustomFileFormatTransactionDataParser returns a new custom transaction data parser according to the file type and encoding
|
||||
func CreateNewCustomFileFormatTransactionDataParser(fileType string, fileEncoding string) (custom.CustomTransactionDataParser, error) {
|
||||
if custom.IsDelimiterSeparatedValuesFileType(fileType) {
|
||||
return custom.CreateNewCustomTransactionDataDsvFileParser(fileType, fileEncoding)
|
||||
} else if custom.IsCustomExcelFileType(fileType) {
|
||||
return custom.CreateNewCustomTransactionDataExcelFileParser(fileType)
|
||||
} else {
|
||||
return nil, errs.ErrImportFileTypeNotSupported
|
||||
}
|
||||
}
|
||||
|
||||
// CreateNewDelimiterSeparatedValuesDataImporter returns a new delimiter-separated values data importer according to the file type and encoding
|
||||
func CreateNewDelimiterSeparatedValuesDataImporter(fileType string, fileEncoding string, columnIndexMapping map[datatable.TransactionDataTableColumn]int, transactionTypeNameMapping map[string]models.TransactionType, hasHeaderLine bool, timeFormat string, timezoneFormat string, amountDecimalSeparator string, amountDigitGroupingSymbol string, geoLocationSeparator string, geoLocationOrder string, transactionTagSeparator string) (converter.TransactionDataImporter, error) {
|
||||
return dsv.CreateNewCustomTransactionDataDsvFileImporter(fileType, fileEncoding, columnIndexMapping, transactionTypeNameMapping, hasHeaderLine, timeFormat, timezoneFormat, amountDecimalSeparator, amountDigitGroupingSymbol, geoLocationSeparator, geoLocationOrder, transactionTagSeparator)
|
||||
// CreateNewCustomTransactionDataImporter returns a new custom transaction data importer according to the file type and encoding
|
||||
func CreateNewCustomTransactionDataImporter(fileType string, fileEncoding string, columnIndexMapping map[datatable.TransactionDataTableColumn]int, transactionTypeNameMapping map[string]models.TransactionType, hasHeaderLine bool, timeFormat string, timezoneFormat string, amountDecimalSeparator string, amountDigitGroupingSymbol string, geoLocationSeparator string, geoLocationOrder string, transactionTagSeparator string) (converter.TransactionDataImporter, error) {
|
||||
if custom.IsDelimiterSeparatedValuesFileType(fileType) {
|
||||
return custom.CreateNewCustomTransactionDataDsvFileImporter(fileType, fileEncoding, columnIndexMapping, transactionTypeNameMapping, hasHeaderLine, timeFormat, timezoneFormat, amountDecimalSeparator, amountDigitGroupingSymbol, geoLocationSeparator, geoLocationOrder, transactionTagSeparator)
|
||||
} else if custom.IsCustomExcelFileType(fileType) {
|
||||
return custom.CreateNewCustomTransactionDataExcelFileImporter(fileType, columnIndexMapping, transactionTypeNameMapping, hasHeaderLine, timeFormat, timezoneFormat, amountDecimalSeparator, amountDigitGroupingSymbol, geoLocationSeparator, geoLocationOrder, transactionTagSeparator)
|
||||
} else {
|
||||
return nil, errs.ErrImportFileTypeNotSupported
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,6 +170,29 @@ func TestWeChatPayCsvFileImporterParseImportedData_ParseInvalidType(t *testing.T
|
||||
assert.EqualError(t, err, errs.ErrNotFoundTransactionDataInFile.Message)
|
||||
}
|
||||
|
||||
func TestWeChatPayCsvFileImporterParseImportedData_ParseAmountWithThousandSeparator(t *testing.T) {
|
||||
importer := WeChatPayTransactionDataCsvFileImporter
|
||||
context := core.NewNullContext()
|
||||
|
||||
user := &models.User{
|
||||
Uid: 1234567890,
|
||||
DefaultCurrency: "CNY",
|
||||
}
|
||||
|
||||
data1 := "微信支付账单明细,,,,\n" +
|
||||
"微信昵称:[xxx],,,,\n" +
|
||||
"起始时间:[2024-01-01 00:00:00] 终止时间:[2024-09-01 23:59:59],,,,\n" +
|
||||
",,,,\n" +
|
||||
"----------------------微信支付账单明细列表--------------------,,,,\n" +
|
||||
"交易时间,交易类型,收/支,金额(元),支付方式,当前状态\n" +
|
||||
"2024-09-01 01:23:45,二维码收款,收入,\"¥1,234.56\",/,已收钱\n"
|
||||
allNewTransactions, _, _, _, _, _, err := importer.ParseImportedData(context, user, []byte(data1), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
|
||||
assert.Nil(t, err)
|
||||
|
||||
assert.Equal(t, 1, len(allNewTransactions))
|
||||
assert.Equal(t, int64(123456), allNewTransactions[0].Amount)
|
||||
}
|
||||
|
||||
func TestWeChatPayCsvFileImporterParseImportedData_ParseInvalidAmount(t *testing.T) {
|
||||
importer := WeChatPayTransactionDataCsvFileImporter
|
||||
context := core.NewNullContext()
|
||||
|
||||
@@ -71,7 +71,7 @@ func (p *weChatPayTransactionDataRowParser) Parse(ctx core.Context, user *models
|
||||
}
|
||||
|
||||
if p.hasOriginalColumn(wechatPayTransactionAmountColumnName) {
|
||||
amount, success := utils.ParseFirstConsecutiveNumber(dataRow.GetData(wechatPayTransactionAmountColumnName))
|
||||
amount, success := utils.ParseFirstConsecutiveNumber(strings.ReplaceAll(dataRow.GetData(wechatPayTransactionAmountColumnName), ",", ""))
|
||||
|
||||
if !success {
|
||||
log.Errorf(ctx, "[wechat_pay_transaction_data_row_parser.Parse] cannot parse amount \"%s\" of transaction in row \"%s\"", dataRow.GetData(wechatPayTransactionAmountColumnName), rowId)
|
||||
|
||||
@@ -1,4 +1,21 @@
|
||||
package core
|
||||
|
||||
import "fmt"
|
||||
|
||||
// ApplicationName represents the application name
|
||||
const ApplicationName = "ezBookkeeping"
|
||||
|
||||
// Version, CommitHash and BuildTime are set at build
|
||||
var (
|
||||
Version string
|
||||
CommitHash string
|
||||
BuildTime string
|
||||
)
|
||||
|
||||
func GetOutgoingUserAgent() string {
|
||||
if Version == "" {
|
||||
return ApplicationName
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s/%s", ApplicationName, Version)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,15 @@ import (
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
// TokenUserAgentCreatedViaCli is the user agent of token created via cli
|
||||
const TokenUserAgentCreatedViaCli = ApplicationName + " Cli"
|
||||
|
||||
// TokenUserAgentForAPI is the user agent for API token
|
||||
const TokenUserAgentForAPI = ApplicationName + " API"
|
||||
|
||||
// TokenUserAgentForMCP is the user agent for MCP token
|
||||
const TokenUserAgentForMCP = ApplicationName + " MCP"
|
||||
|
||||
// TokenType represents token type
|
||||
type TokenType byte
|
||||
|
||||
|
||||
@@ -108,6 +108,6 @@ func (e *CommonHttpExchangeRatesDataProvider) GetLatestExchangeRates(c core.Cont
|
||||
func newCommonHttpExchangeRatesDataProvider(config *settings.Config, dataSource HttpExchangeRatesDataSource) *CommonHttpExchangeRatesDataProvider {
|
||||
return &CommonHttpExchangeRatesDataProvider{
|
||||
dataSource: dataSource,
|
||||
httpClient: httpclient.NewHttpClient(config.ExchangeRatesRequestTimeout, config.ExchangeRatesProxy, config.ExchangeRatesSkipTLSVerify, settings.GetUserAgent(), config.EnableDebugLog),
|
||||
httpClient: httpclient.NewHttpClient(config.ExchangeRatesRequestTimeout, config.ExchangeRatesProxy, config.ExchangeRatesSkipTLSVerify, core.GetOutgoingUserAgent(), config.EnableDebugLog),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,21 +14,6 @@ import (
|
||||
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
||||
)
|
||||
|
||||
func TestExchangeRatesApiLatestExchangeRateHandler_ReserveBankOfAustraliaDataSource(t *testing.T) {
|
||||
exchangeRateResponse := executeLatestExchangeRateHandler(t, settings.ReserveBankOfAustraliaDataSource)
|
||||
|
||||
if exchangeRateResponse == nil {
|
||||
return
|
||||
}
|
||||
|
||||
assert.Equal(t, "AUD", exchangeRateResponse.BaseCurrency)
|
||||
|
||||
supportedCurrencyCodes := []string{"CAD", "CHF", "CNY", "EUR", "GBP", "HKD", "IDR", "INR", "JPY", "KRW",
|
||||
"MYR", "NZD", "PGK", "PHP", "SGD", "THB", "TWD", "USD", "VND"}
|
||||
|
||||
checkExchangeRatesHaveSpecifiedCurrencies(t, exchangeRateResponse.BaseCurrency, supportedCurrencyCodes, exchangeRateResponse.ExchangeRates)
|
||||
}
|
||||
|
||||
func TestExchangeRatesApiLatestExchangeRateHandler_BankOfCanadaDataSource(t *testing.T) {
|
||||
exchangeRateResponse := executeLatestExchangeRateHandler(t, settings.BankOfCanadaDataSource)
|
||||
|
||||
@@ -295,22 +280,6 @@ func TestExchangeRatesApiLatestExchangeRateHandler_CentralBankOfUzbekistanDataSo
|
||||
checkExchangeRatesHaveSpecifiedCurrencies(t, exchangeRateResponse.BaseCurrency, supportedCurrencyCodes, exchangeRateResponse.ExchangeRates)
|
||||
}
|
||||
|
||||
func TestExchangeRatesApiLatestExchangeRateHandler_InternationalMonetaryFundDataSource(t *testing.T) {
|
||||
exchangeRateResponse := executeLatestExchangeRateHandler(t, settings.InternationalMonetaryFundDataSource)
|
||||
|
||||
if exchangeRateResponse == nil {
|
||||
return
|
||||
}
|
||||
|
||||
assert.Equal(t, "USD", exchangeRateResponse.BaseCurrency)
|
||||
|
||||
supportedCurrencyCodes := []string{"AED", "AUD", "BND", "BRL", "BWP", "CAD", "CHF", "CLP", "CNY", "CZK",
|
||||
"DKK", "DZD", "EUR", "GBP", "ILS", "INR", "JPY", "KRW", "KWD", "MUR", "MXN", "MYR", "NOK", "NZD",
|
||||
"OMR", "PEN", "PHP", "PLN", "QAR", "SAR", "SEK", "SGD", "THB", "TTD", "UYU"}
|
||||
|
||||
checkExchangeRatesHaveSpecifiedCurrencies(t, exchangeRateResponse.BaseCurrency, supportedCurrencyCodes, exchangeRateResponse.ExchangeRates)
|
||||
}
|
||||
|
||||
func executeLatestExchangeRateHandler(t *testing.T, dataSourceType string) *models.LatestExchangeRateResponse {
|
||||
if os.Getenv("BUILD_PIPELINE") == "1" && os.Getenv("CHECK_3RD_API") != "1" {
|
||||
return nil
|
||||
|
||||
@@ -19,10 +19,7 @@ var (
|
||||
|
||||
// InitializeExchangeRatesDataSource initializes the current exchange rates data source according to the config
|
||||
func InitializeExchangeRatesDataSource(config *settings.Config) error {
|
||||
if config.ExchangeRatesDataSource == settings.ReserveBankOfAustraliaDataSource {
|
||||
Container.current = newCommonHttpExchangeRatesDataProvider(config, &ReserveBankOfAustraliaDataSource{})
|
||||
return nil
|
||||
} else if config.ExchangeRatesDataSource == settings.BankOfCanadaDataSource {
|
||||
if config.ExchangeRatesDataSource == settings.BankOfCanadaDataSource {
|
||||
Container.current = newCommonHttpExchangeRatesDataProvider(config, &BankOfCanadaDataSource{})
|
||||
return nil
|
||||
} else if config.ExchangeRatesDataSource == settings.CzechNationalBankDataSource {
|
||||
@@ -67,9 +64,6 @@ func InitializeExchangeRatesDataSource(config *settings.Config) error {
|
||||
} else if config.ExchangeRatesDataSource == settings.CentralBankOfUzbekistanDataSource {
|
||||
Container.current = newCommonHttpExchangeRatesDataProvider(config, &CentralBankOfUzbekistanDataSource{})
|
||||
return nil
|
||||
} else if config.ExchangeRatesDataSource == settings.InternationalMonetaryFundDataSource {
|
||||
Container.current = newCommonHttpExchangeRatesDataProvider(config, &InternationalMonetaryFundDataSource{})
|
||||
return nil
|
||||
} else if config.ExchangeRatesDataSource == settings.UserCustomExchangeRatesDataSource {
|
||||
Container.current = newUserCustomExchangeRatesDataProvider()
|
||||
return nil
|
||||
|
||||
@@ -1,238 +0,0 @@
|
||||
package exchangerates
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
orderedmap "github.com/wk8/go-ordered-map/v2"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/validators"
|
||||
)
|
||||
|
||||
const internationalMonetaryFundExchangeRateUrl = "https://www.imf.org/external/np/fin/data/rms_five.aspx?tsvflag=Y"
|
||||
const internationalMonetaryFundExchangeRateReferenceUrl = "https://www.imf.org/external/np/fin/data/param_rms_mth.aspx"
|
||||
const internationalMonetaryFundDataSource = "International Monetary Fund"
|
||||
const internationalMonetaryFundBaseCurrency = "USD"
|
||||
|
||||
const internationalMonetaryFundDataUpdateDateFormat = "January 02, 2006 15:04"
|
||||
const internationalMonetaryFundDataUpdateDateTimezone = "America/New_York"
|
||||
|
||||
var internationalMonetaryFundCurrencyNameCodeMap map[string]string
|
||||
|
||||
// InternationalMonetaryFundDataSource defines the structure of exchange rates data source of international monetary fund
|
||||
type InternationalMonetaryFundDataSource struct {
|
||||
HttpExchangeRatesDataSource
|
||||
}
|
||||
|
||||
func init() {
|
||||
internationalMonetaryFundCurrencyNameCodeMap = make(map[string]string, 38)
|
||||
internationalMonetaryFundCurrencyNameCodeMap["Chinese yuan"] = "CNY"
|
||||
internationalMonetaryFundCurrencyNameCodeMap["Euro"] = "EUR"
|
||||
internationalMonetaryFundCurrencyNameCodeMap["Japanese yen"] = "JPY"
|
||||
internationalMonetaryFundCurrencyNameCodeMap["U.K. pound"] = "GBP"
|
||||
internationalMonetaryFundCurrencyNameCodeMap["U.S. dollar"] = "USD"
|
||||
internationalMonetaryFundCurrencyNameCodeMap["Algerian dinar"] = "DZD"
|
||||
internationalMonetaryFundCurrencyNameCodeMap["Australian dollar"] = "AUD"
|
||||
internationalMonetaryFundCurrencyNameCodeMap["Botswana pula"] = "BWP"
|
||||
internationalMonetaryFundCurrencyNameCodeMap["Brazilian real"] = "BRL"
|
||||
internationalMonetaryFundCurrencyNameCodeMap["Brunei dollar"] = "BND"
|
||||
internationalMonetaryFundCurrencyNameCodeMap["Canadian dollar"] = "CAD"
|
||||
internationalMonetaryFundCurrencyNameCodeMap["Chilean peso"] = "CLP"
|
||||
internationalMonetaryFundCurrencyNameCodeMap["Czech koruna"] = "CZK"
|
||||
internationalMonetaryFundCurrencyNameCodeMap["Danish krone"] = "DKK"
|
||||
internationalMonetaryFundCurrencyNameCodeMap["Indian rupee"] = "INR"
|
||||
internationalMonetaryFundCurrencyNameCodeMap["Israeli New Shekel"] = "ILS"
|
||||
internationalMonetaryFundCurrencyNameCodeMap["Korean won"] = "KRW"
|
||||
internationalMonetaryFundCurrencyNameCodeMap["Kuwaiti dinar"] = "KWD"
|
||||
internationalMonetaryFundCurrencyNameCodeMap["Malaysian ringgit"] = "MYR"
|
||||
internationalMonetaryFundCurrencyNameCodeMap["Mauritian rupee"] = "MUR"
|
||||
internationalMonetaryFundCurrencyNameCodeMap["Mexican peso"] = "MXN"
|
||||
internationalMonetaryFundCurrencyNameCodeMap["New Zealand dollar"] = "NZD"
|
||||
internationalMonetaryFundCurrencyNameCodeMap["Norwegian krone"] = "NOK"
|
||||
internationalMonetaryFundCurrencyNameCodeMap["Omani rial"] = "OMR"
|
||||
internationalMonetaryFundCurrencyNameCodeMap["Peruvian sol"] = "PEN"
|
||||
internationalMonetaryFundCurrencyNameCodeMap["Philippine peso"] = "PHP"
|
||||
internationalMonetaryFundCurrencyNameCodeMap["Polish zloty"] = "PLN"
|
||||
internationalMonetaryFundCurrencyNameCodeMap["Qatari riyal"] = "QAR"
|
||||
internationalMonetaryFundCurrencyNameCodeMap["Saudi Arabian riyal"] = "SAR"
|
||||
internationalMonetaryFundCurrencyNameCodeMap["Singapore dollar"] = "SGD"
|
||||
internationalMonetaryFundCurrencyNameCodeMap["Swedish krona"] = "SEK"
|
||||
internationalMonetaryFundCurrencyNameCodeMap["Swiss franc"] = "CHF"
|
||||
internationalMonetaryFundCurrencyNameCodeMap["Thai baht"] = "THB"
|
||||
internationalMonetaryFundCurrencyNameCodeMap["Trinidadian dollar"] = "TTD"
|
||||
internationalMonetaryFundCurrencyNameCodeMap["U.A.E. dirham"] = "AED"
|
||||
internationalMonetaryFundCurrencyNameCodeMap["Uruguayan peso"] = "UYU"
|
||||
}
|
||||
|
||||
// BuildRequests returns the international monetary fund exchange rates http requests
|
||||
func (e *InternationalMonetaryFundDataSource) BuildRequests() ([]*http.Request, error) {
|
||||
req, err := http.NewRequest("GET", internationalMonetaryFundExchangeRateUrl, nil)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", "") // Do not set custom user agent
|
||||
|
||||
return []*http.Request{req}, nil
|
||||
}
|
||||
|
||||
// Parse returns the common response entity according to the international monetary fund data source raw response
|
||||
func (e *InternationalMonetaryFundDataSource) Parse(c core.Context, content []byte) (*models.LatestExchangeRateResponse, error) {
|
||||
lines := strings.Split(string(content), "\n")
|
||||
|
||||
if len(lines) < 1 {
|
||||
log.Errorf(c, "[international_monetary_fund_datasource.Parse] content is invalid, content is %s", string(content))
|
||||
return nil, errs.ErrFailedToRequestRemoteApi
|
||||
}
|
||||
|
||||
exchangeRatesToSDR := orderedmap.New[string, float64]()
|
||||
latestUpdateDate := ""
|
||||
|
||||
findSDRsPerCurrencyUnitLine := false
|
||||
findExchangeRateDataHeader := false
|
||||
|
||||
for i := 0; i < len(lines); i++ {
|
||||
line := lines[i]
|
||||
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
line = strings.ReplaceAll(line, "\r", "")
|
||||
|
||||
if strings.Index(line, "Currency units per SDR") == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
if strings.Index(line, "SDRs per Currency unit") == 0 {
|
||||
findSDRsPerCurrencyUnitLine = true
|
||||
continue
|
||||
}
|
||||
|
||||
if findExchangeRateDataHeader {
|
||||
items := strings.Split(line, "\t")
|
||||
|
||||
if len(items) != 6 {
|
||||
continue
|
||||
}
|
||||
|
||||
currencyCode, exchangeRate := e.parseExchangeRate(c, line, items)
|
||||
|
||||
if currencyCode != nil && exchangeRate != nil {
|
||||
exchangeRatesToSDR.Set(*currencyCode, *exchangeRate)
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if findSDRsPerCurrencyUnitLine {
|
||||
items := strings.Split(line, "\t")
|
||||
|
||||
if len(items) != 6 {
|
||||
continue
|
||||
}
|
||||
|
||||
if items[0] == "Currency" {
|
||||
findExchangeRateDataHeader = true
|
||||
latestUpdateDate = items[1]
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if latestUpdateDate == "" {
|
||||
log.Errorf(c, "[international_monetary_fund_datasource.Parse] latest update date is empty")
|
||||
return nil, errs.ErrFailedToRequestRemoteApi
|
||||
}
|
||||
|
||||
if exchangeRatesToSDR.Len() < 1 {
|
||||
log.Errorf(c, "[international_monetary_fund_datasource.Parse] exchange rates date is empty")
|
||||
return nil, errs.ErrFailedToRequestRemoteApi
|
||||
}
|
||||
|
||||
defaultCurrencyExchangeRateToSDR, exists := exchangeRatesToSDR.Get(internationalMonetaryFundBaseCurrency)
|
||||
|
||||
if !exists {
|
||||
log.Errorf(c, "[international_monetary_fund_datasource.Parse] exchange rates date does not have default currency \"%s\"", internationalMonetaryFundBaseCurrency)
|
||||
return nil, errs.ErrFailedToRequestRemoteApi
|
||||
}
|
||||
|
||||
exchangeRates := make(models.LatestExchangeRateSlice, 0, exchangeRatesToSDR.Len())
|
||||
|
||||
for pair := exchangeRatesToSDR.Oldest(); pair != nil; pair = pair.Next() {
|
||||
exchangeRates = append(exchangeRates, &models.LatestExchangeRate{
|
||||
Currency: pair.Key,
|
||||
Rate: utils.Float64ToString(defaultCurrencyExchangeRateToSDR / pair.Value),
|
||||
})
|
||||
}
|
||||
|
||||
timezone, err := time.LoadLocation(internationalMonetaryFundDataUpdateDateTimezone)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[international_monetary_fund_datasource.Parse] failed to get timezone, timezone name is %s", internationalMonetaryFundDataUpdateDateTimezone)
|
||||
return nil, errs.ErrFailedToRequestRemoteApi
|
||||
}
|
||||
|
||||
updateDateTime := latestUpdateDate + " 11:00" // The IMF posts Representative and SDR exchange rates every 20 minutes from 11:00 AM to 6:00 PM U.S. EST Monday to Friday except for these holidays
|
||||
updateTime, err := time.ParseInLocation(internationalMonetaryFundDataUpdateDateFormat, updateDateTime, timezone)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[international_monetary_fund_datasource.Parse] failed to parse update date, datetime is %s", updateDateTime)
|
||||
return nil, errs.ErrFailedToRequestRemoteApi
|
||||
}
|
||||
|
||||
latestExchangeRateResp := &models.LatestExchangeRateResponse{
|
||||
DataSource: internationalMonetaryFundDataSource,
|
||||
ReferenceUrl: internationalMonetaryFundExchangeRateReferenceUrl,
|
||||
UpdateTime: updateTime.Unix(),
|
||||
BaseCurrency: internationalMonetaryFundBaseCurrency,
|
||||
ExchangeRates: exchangeRates,
|
||||
}
|
||||
|
||||
return latestExchangeRateResp, nil
|
||||
}
|
||||
|
||||
func (e *InternationalMonetaryFundDataSource) parseExchangeRate(c core.Context, line string, lineItems []string) (*string, *float64) {
|
||||
currencyCode, exists := internationalMonetaryFundCurrencyNameCodeMap[lineItems[0]]
|
||||
|
||||
if !exists {
|
||||
log.Warnf(c, "[international_monetary_fund_datasource.parseExchangeRate] unknown currency name %s, line is %s", lineItems[0], line)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if _, exists := validators.AllCurrencyNames[currencyCode]; !exists {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
for i := 1; i < 6; i++ {
|
||||
item := lineItems[i]
|
||||
|
||||
if item == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
rate, err := utils.StringToFloat64(item)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[international_monetary_fund_datasource.parseExchangeRate] failed to parse rate, line is %s", line)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if rate <= 0 {
|
||||
log.Warnf(c, "[international_monetary_fund_datasource.parseExchangeRate] rate is invalid, line is %s", line)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return ¤cyCode, &rate
|
||||
}
|
||||
|
||||
log.Warnf(c, "[international_monetary_fund_datasource.parseExchangeRate] no exchange rate data exists for currency \"%s\", line is %s", currencyCode, line)
|
||||
return nil, nil
|
||||
}
|
||||
@@ -1,188 +0,0 @@
|
||||
package exchangerates
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||
)
|
||||
|
||||
const internationalMonetaryFundMinimumRequiredContent = "SDRs per Currency unit and Currency units per SDR (1)\n" +
|
||||
"last five days\n" +
|
||||
"SDRs per Currency unit (2)\n" +
|
||||
"\n" +
|
||||
"Currency\tAugust 28, 2024\tAugust 27, 2024\tAugust 26, 2024\tAugust 23, 2024\tAugust 22, 2024\n" +
|
||||
"Chinese yuan\t0.1040520000\t0.1039250000\t0.1040370000\t0.1040850000\t0.1040570000\n" +
|
||||
"U.S. dollar\t0.7417320000\t0.7410250000\t0.7408270000\t0.7429280000\t0.7423020000\n"
|
||||
|
||||
func TestInternationalMonetaryFundDataSource_StandardDataExtractBaseCurrency(t *testing.T) {
|
||||
dataSource := &InternationalMonetaryFundDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte(internationalMonetaryFundMinimumRequiredContent))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Equal(t, "USD", actualLatestExchangeRateResponse.BaseCurrency)
|
||||
}
|
||||
|
||||
func TestInternationalMonetaryFundDataSource_StandardDataExtractUpdateTime(t *testing.T) {
|
||||
dataSource := &InternationalMonetaryFundDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte(internationalMonetaryFundMinimumRequiredContent))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Equal(t, int64(1724857200), actualLatestExchangeRateResponse.UpdateTime)
|
||||
}
|
||||
|
||||
func TestInternationalMonetaryFundDataSource_StandardDataExtractExchangeRates(t *testing.T) {
|
||||
dataSource := &InternationalMonetaryFundDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte(internationalMonetaryFundMinimumRequiredContent))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Contains(t, actualLatestExchangeRateResponse.ExchangeRates, &models.LatestExchangeRate{
|
||||
Currency: "USD",
|
||||
Rate: "1",
|
||||
})
|
||||
assert.Contains(t, actualLatestExchangeRateResponse.ExchangeRates, &models.LatestExchangeRate{
|
||||
Currency: "CNY",
|
||||
Rate: "7.128474224426247",
|
||||
})
|
||||
}
|
||||
|
||||
func TestInternationalMonetaryFundDataSource_BlankContent(t *testing.T) {
|
||||
dataSource := &InternationalMonetaryFundDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
_, err := dataSource.Parse(context, []byte(""))
|
||||
assert.NotEqual(t, nil, err)
|
||||
}
|
||||
|
||||
func TestInternationalMonetaryFundDataSource_OnlyHeader(t *testing.T) {
|
||||
dataSource := &InternationalMonetaryFundDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
_, err := dataSource.Parse(context, []byte("SDRs per Currency unit and Currency units per SDR (1)\n"+
|
||||
"last five days\n"+
|
||||
"SDRs per Currency unit (2)"))
|
||||
assert.NotEqual(t, nil, err)
|
||||
}
|
||||
|
||||
func TestInternationalMonetaryFundDataSource_OnlyHeaderAndTitle(t *testing.T) {
|
||||
dataSource := &InternationalMonetaryFundDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
_, err := dataSource.Parse(context, []byte("SDRs per Currency unit and Currency units per SDR (1)\n"+
|
||||
"last five days\n"+
|
||||
"SDRs per Currency unit (2)\n"+
|
||||
"\n"+
|
||||
"Currency\tAugust 28, 2024\tAugust 27, 2024\tAugust 26, 2024\tAugust 23, 2024\tAugust 22, 2024\n"))
|
||||
assert.NotEqual(t, nil, err)
|
||||
}
|
||||
|
||||
func TestInternationalMonetaryFundDataSource_MissingHeader(t *testing.T) {
|
||||
dataSource := &InternationalMonetaryFundDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
_, err := dataSource.Parse(context, []byte("Currency\tAugust 28, 2024\tAugust 27, 2024\tAugust 26, 2024\tAugust 23, 2024\tAugust 22, 2024\n"+
|
||||
"Chinese yuan\t0.1040520000\t0.1039250000\t0.1040370000\t0.1040850000\t0.1040570000\n"+
|
||||
"U.S. dollar\t0.7417320000\t0.7410250000\t0.7408270000\t0.7429280000\t0.7423020000\n"))
|
||||
assert.NotEqual(t, nil, err)
|
||||
}
|
||||
|
||||
func TestInternationalMonetaryFundDataSource_MissingDefaultCurrencyData(t *testing.T) {
|
||||
dataSource := &InternationalMonetaryFundDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
_, err := dataSource.Parse(context, []byte("SDRs per Currency unit and Currency units per SDR (1)\n"+
|
||||
"last five days\n"+
|
||||
"SDRs per Currency unit (2)\n"+
|
||||
"\n"+
|
||||
"Currency\tAugust 28, 2024\tAugust 27, 2024\tAugust 26, 2024\tAugust 23, 2024\tAugust 22, 2024\n"+
|
||||
"Chinese yuan\t0.1040520000\t0.1039250000\t0.1040370000\t0.1040850000\t0.1040570000\n"))
|
||||
assert.NotEqual(t, nil, err)
|
||||
}
|
||||
|
||||
func TestInternationalMonetaryFundDataSource_DefaultCurrencyDataInvalid(t *testing.T) {
|
||||
dataSource := &InternationalMonetaryFundDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
_, err := dataSource.Parse(context, []byte("SDRs per Currency unit and Currency units per SDR (1)\n"+
|
||||
"last five days\n"+
|
||||
"SDRs per Currency unit (2)\n"+
|
||||
"\n"+
|
||||
"Currency\tAugust 28, 2024\tAugust 27, 2024\tAugust 26, 2024\tAugust 23, 2024\tAugust 22, 2024\n"+
|
||||
"Chinese yuan\t0.1040520000\t0.1039250000\t0.1040370000\t0.1040850000\t0.1040570000\n"+
|
||||
"U.S. dollar\t0\t0\t0\t0\t0\n"))
|
||||
assert.NotEqual(t, nil, err)
|
||||
}
|
||||
|
||||
func TestInternationalMonetaryFundDataSource_InvalidCurrency(t *testing.T) {
|
||||
dataSource := &InternationalMonetaryFundDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("SDRs per Currency unit and Currency units per SDR (1)\n"+
|
||||
"last five days\n"+
|
||||
"SDRs per Currency unit (2)\n"+
|
||||
"\n"+
|
||||
"Currency\tAugust 28, 2024\tAugust 27, 2024\tAugust 26, 2024\tAugust 23, 2024\tAugust 22, 2024\n"+
|
||||
"Foo bar\t0.1040520000\t0.1039250000\t0.1040370000\t0.1040850000\t0.1040570000\n"+
|
||||
"U.S. dollar\t0.7417320000\t0.7410250000\t0.7408270000\t0.7429280000\t0.7423020000\n"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 1)
|
||||
}
|
||||
|
||||
func TestInternationalMonetaryFundDataSource_InvalidRate(t *testing.T) {
|
||||
dataSource := &InternationalMonetaryFundDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("SDRs per Currency unit and Currency units per SDR (1)\n"+
|
||||
"last five days\n"+
|
||||
"SDRs per Currency unit (2)\n"+
|
||||
"\n"+
|
||||
"Currency\tAugust 28, 2024\tAugust 27, 2024\tAugust 26, 2024\tAugust 23, 2024\tAugust 22, 2024\n"+
|
||||
"Chinese yuan\tnull\tnull\tnull\tnull\tnull\n"+
|
||||
"U.S. dollar\t0.7417320000\t0.7410250000\t0.7408270000\t0.7429280000\t0.7423020000\n"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 1)
|
||||
|
||||
actualLatestExchangeRateResponse, err = dataSource.Parse(context, []byte("SDRs per Currency unit and Currency units per SDR (1)\n"+
|
||||
"last five days\n"+
|
||||
"SDRs per Currency unit (2)\n"+
|
||||
"\n"+
|
||||
"Currency\tAugust 28, 2024\tAugust 27, 2024\tAugust 26, 2024\tAugust 23, 2024\tAugust 22, 2024\n"+
|
||||
"Chinese yuan\t0\t0\t0\t0\t0\n"+
|
||||
"U.S. dollar\t0.7417320000\t0.7410250000\t0.7408270000\t0.7429280000\t0.7423020000\n"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 1)
|
||||
|
||||
actualLatestExchangeRateResponse, err = dataSource.Parse(context, []byte("SDRs per Currency unit and Currency units per SDR (1)\n"+
|
||||
"last five days\n"+
|
||||
"SDRs per Currency unit (2)\n"+
|
||||
"\n"+
|
||||
"Currency\tAugust 28, 2024\tAugust 27, 2024\tAugust 26, 2024\tAugust 23, 2024\tAugust 22, 2024\n"+
|
||||
"Chinese yuan\t\t\t\t\t\n"+
|
||||
"U.S. dollar\t0.7417320000\t0.7410250000\t0.7408270000\t0.7429280000\t0.7423020000\n"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 1)
|
||||
}
|
||||
|
||||
func TestInternationalMonetaryFundDataSource_LatestDateNotHasRate(t *testing.T) {
|
||||
dataSource := &InternationalMonetaryFundDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("SDRs per Currency unit and Currency units per SDR (1)\n"+
|
||||
"last five days\n"+
|
||||
"SDRs per Currency unit (2)\n"+
|
||||
"\n"+
|
||||
"Currency\tAugust 28, 2024\tAugust 27, 2024\tAugust 26, 2024\tAugust 23, 2024\tAugust 22, 2024\n"+
|
||||
"U.S. dollar\t0.7417320000\t0.7410250000\t0.7408270000\t0.7429280000\t0.7423020000\n"+
|
||||
"U.A.E. dirham\t\t0.2017770000\t0.2017230000\t\t0.2021240000\n"))
|
||||
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Contains(t, actualLatestExchangeRateResponse.ExchangeRates, &models.LatestExchangeRate{
|
||||
Currency: "AED",
|
||||
Rate: "3.675998751096507",
|
||||
})
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
package exchangerates
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/html/charset"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/validators"
|
||||
)
|
||||
|
||||
const reserveBankOfAustraliaExchangeRateUrl = "https://www.rba.gov.au/rss/rss-cb-exchange-rates.xml"
|
||||
const reserveBankOfAustraliaExchangeRateReferenceUrl = "https://www.rba.gov.au/statistics/frequency/exchange-rates.html"
|
||||
const reserveBankOfAustraliaDataSource = "Reserve Bank of Australia"
|
||||
const reserveBankOfAustraliaBaseCurrency = "AUD"
|
||||
|
||||
const reserveBankOfAustraliaDataUpdateDateFormat = "2006-01-02T15:04:05Z07:00"
|
||||
|
||||
// ReserveBankOfAustraliaDataSource defines the structure of exchange rates data source of the reserve bank of Australia
|
||||
type ReserveBankOfAustraliaDataSource struct {
|
||||
HttpExchangeRatesDataSource
|
||||
}
|
||||
|
||||
// ReserveBankOfAustraliaData represents the whole data from the reserve bank of Australia
|
||||
type ReserveBankOfAustraliaData struct {
|
||||
XMLName xml.Name `xml:"RDF"`
|
||||
Channel *ReserveBankOfAustraliaRssChannel `xml:"channel"`
|
||||
Items []*ReserveBankOfAustraliaRssItem `xml:"item"`
|
||||
}
|
||||
|
||||
// ReserveBankOfAustraliaRssChannel represents the rss channel from the reserve bank of Australia
|
||||
type ReserveBankOfAustraliaRssChannel struct {
|
||||
Date string `xml:"date"`
|
||||
}
|
||||
|
||||
// ReserveBankOfAustraliaRssItem represents the rss item from the reserve bank of Australia
|
||||
type ReserveBankOfAustraliaRssItem struct {
|
||||
Statistics *ReserveBankOfAustraliaItemStatistics `xml:"statistics"`
|
||||
}
|
||||
|
||||
// ReserveBankOfAustraliaItemStatistics represents the item statistics from the reserve bank of Australia
|
||||
type ReserveBankOfAustraliaItemStatistics struct {
|
||||
ExchangeRate *ReserveBankOfAustraliaExchangeRate `xml:"exchangeRate"`
|
||||
}
|
||||
|
||||
// ReserveBankOfAustraliaExchangeRate represents the exchange rate from the reserve bank of Australia
|
||||
type ReserveBankOfAustraliaExchangeRate struct {
|
||||
BaseCurrency string `xml:"baseCurrency"`
|
||||
TargetCurrency string `xml:"targetCurrency"`
|
||||
Observation *ReserveBankOfAustraliaExchangeRateObservation `xml:"observation"`
|
||||
}
|
||||
|
||||
// ReserveBankOfAustraliaExchangeRateObservation represents the exchange rate data from the reserve bank of Australia
|
||||
type ReserveBankOfAustraliaExchangeRateObservation struct {
|
||||
Value string `xml:"value"`
|
||||
Unit string `xml:"unit"`
|
||||
}
|
||||
|
||||
// ToLatestExchangeRateResponse returns a view-object according to original data from the reserve bank of Australia
|
||||
func (e *ReserveBankOfAustraliaData) ToLatestExchangeRateResponse(c core.Context) *models.LatestExchangeRateResponse {
|
||||
if e.Channel == nil {
|
||||
log.Errorf(c, "[reserve_bank_of_australia_datasource.ToLatestExchangeRateResponse] rss channel does not exist")
|
||||
return nil
|
||||
}
|
||||
|
||||
if len(e.Items) < 1 {
|
||||
log.Errorf(c, "[reserve_bank_of_australia_datasource.ToLatestExchangeRateResponse] rss items is empty")
|
||||
return nil
|
||||
}
|
||||
|
||||
exchangeRates := make(models.LatestExchangeRateSlice, 0, len(e.Items))
|
||||
|
||||
for i := 0; i < len(e.Items); i++ {
|
||||
item := e.Items[i]
|
||||
|
||||
if item.Statistics == nil || item.Statistics.ExchangeRate == nil || item.Statistics.ExchangeRate.Observation == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if item.Statistics.ExchangeRate.BaseCurrency != reserveBankOfAustraliaBaseCurrency || item.Statistics.ExchangeRate.Observation.Unit != reserveBankOfAustraliaBaseCurrency {
|
||||
continue
|
||||
}
|
||||
|
||||
if _, exists := validators.AllCurrencyNames[item.Statistics.ExchangeRate.TargetCurrency]; !exists {
|
||||
continue
|
||||
}
|
||||
|
||||
if _, err := utils.StringToFloat64(item.Statistics.ExchangeRate.Observation.Value); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
exchangeRates = append(exchangeRates, item.Statistics.ExchangeRate.ToLatestExchangeRate())
|
||||
}
|
||||
|
||||
updateDateTime := e.Channel.Date
|
||||
updateTime, err := time.Parse(reserveBankOfAustraliaDataUpdateDateFormat, updateDateTime)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[reserve_bank_of_australia_datasource.ToLatestExchangeRateResponse] failed to parse update date, datetime is %s", updateDateTime)
|
||||
return nil
|
||||
}
|
||||
|
||||
latestExchangeRateResp := &models.LatestExchangeRateResponse{
|
||||
DataSource: reserveBankOfAustraliaDataSource,
|
||||
ReferenceUrl: reserveBankOfAustraliaExchangeRateReferenceUrl,
|
||||
UpdateTime: updateTime.Unix(),
|
||||
BaseCurrency: reserveBankOfAustraliaBaseCurrency,
|
||||
ExchangeRates: exchangeRates,
|
||||
}
|
||||
|
||||
return latestExchangeRateResp
|
||||
}
|
||||
|
||||
// ToLatestExchangeRate returns a data pair according to original data from the reserve bank of Australia
|
||||
func (e *ReserveBankOfAustraliaExchangeRate) ToLatestExchangeRate() *models.LatestExchangeRate {
|
||||
return &models.LatestExchangeRate{
|
||||
Currency: e.TargetCurrency,
|
||||
Rate: e.Observation.Value,
|
||||
}
|
||||
}
|
||||
|
||||
// BuildRequests returns the reserve bank of Australia exchange rates http requests
|
||||
func (e *ReserveBankOfAustraliaDataSource) BuildRequests() ([]*http.Request, error) {
|
||||
req, err := http.NewRequest("GET", reserveBankOfAustraliaExchangeRateUrl, nil)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return []*http.Request{req}, nil
|
||||
}
|
||||
|
||||
// Parse returns the common response entity according to the the reserve bank of Australia data source raw response
|
||||
func (e *ReserveBankOfAustraliaDataSource) Parse(c core.Context, content []byte) (*models.LatestExchangeRateResponse, error) {
|
||||
xmlDecoder := xml.NewDecoder(bytes.NewReader(content))
|
||||
xmlDecoder.CharsetReader = charset.NewReaderLabel
|
||||
|
||||
reserveBankOfAustraliaData := &ReserveBankOfAustraliaData{}
|
||||
err := xmlDecoder.Decode(reserveBankOfAustraliaData)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[reserve_bank_of_australia_datasource.Parse] failed to parse xml data, content is %s, because %s", string(content), err.Error())
|
||||
return nil, errs.ErrFailedToRequestRemoteApi
|
||||
}
|
||||
|
||||
latestExchangeRateResponse := reserveBankOfAustraliaData.ToLatestExchangeRateResponse(c)
|
||||
|
||||
if latestExchangeRateResponse == nil {
|
||||
log.Errorf(c, "[reserve_bank_of_australia_datasource.Parse] failed to parse latest exchange rate data, content is %s", string(content))
|
||||
return nil, errs.ErrFailedToRequestRemoteApi
|
||||
}
|
||||
|
||||
return latestExchangeRateResponse, nil
|
||||
}
|
||||
@@ -1,268 +0,0 @@
|
||||
package exchangerates
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||
)
|
||||
|
||||
const reserveBankOfAustraliaMinimumRequiredContent = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" +
|
||||
"<rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\" xmlns:rba=\"https://www.rba.gov.au/statistics/frequency/exchange-rates.html\" xmlns:cb=\"http://www.cbwiki.net/wiki/index.php/Specification_1.2/\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\" xmlns:dcterms=\"http://purl.org/dc/terms/\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns=\"http://purl.org/rss/1.0/\" xsi:schemaLocation=\"http://www.w3.org/1999/02/22-rdf-syntax-ns# rdf.xsd\">\n" +
|
||||
" <channel rdf:about=\"https://www.rba.gov.au/rss/rss-cb-exchange-rates.xml\">\n" +
|
||||
" <dc:date>2021-04-01T16:45:00+11:00</dc:date>\n" +
|
||||
" </channel>\n" +
|
||||
" <item rdf:about=\"https://www.rba.gov.au/statistics/frequency/exchange-rates.html#USD\">\n" +
|
||||
" <cb:statistics rdf:parseType=\"Resource\">\n" +
|
||||
" <cb:exchangeRate rdf:parseType=\"Resource\">\n" +
|
||||
" <cb:observation rdf:parseType=\"Resource\">\n" +
|
||||
" <cb:value>0.7543</cb:value>\n" +
|
||||
" <cb:unit>AUD</cb:unit>\n" +
|
||||
" </cb:observation>\n" +
|
||||
" <cb:baseCurrency>AUD</cb:baseCurrency>\n" +
|
||||
" <cb:targetCurrency>USD</cb:targetCurrency>\n" +
|
||||
" </cb:exchangeRate>\n" +
|
||||
" </cb:statistics>\n" +
|
||||
" </item>\n" +
|
||||
" <item rdf:about=\"https://www.rba.gov.au/statistics/frequency/exchange-rates.html#CNY\">\n" +
|
||||
" <cb:statistics rdf:parseType=\"Resource\">\n" +
|
||||
" <cb:exchangeRate rdf:parseType=\"Resource\">\n" +
|
||||
" <cb:observation rdf:parseType=\"Resource\">\n" +
|
||||
" <cb:value>4.9577</cb:value>\n" +
|
||||
" <cb:unit>AUD</cb:unit>\n" +
|
||||
" </cb:observation>\n" +
|
||||
" <cb:baseCurrency>AUD</cb:baseCurrency>\n" +
|
||||
" <cb:targetCurrency>CNY</cb:targetCurrency>\n" +
|
||||
" </cb:exchangeRate>\n" +
|
||||
" </cb:statistics>\n" +
|
||||
" </item>\n" +
|
||||
"</rdf:RDF>"
|
||||
|
||||
func TestReserveBankOfAustraliaDataSource_StandardDataExtractBaseCurrency(t *testing.T) {
|
||||
dataSource := &ReserveBankOfAustraliaDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte(reserveBankOfAustraliaMinimumRequiredContent))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Equal(t, "AUD", actualLatestExchangeRateResponse.BaseCurrency)
|
||||
}
|
||||
|
||||
func TestReserveBankOfAustraliaDataSource_StandardDataExtractUpdateTime(t *testing.T) {
|
||||
dataSource := &ReserveBankOfAustraliaDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte(reserveBankOfAustraliaMinimumRequiredContent))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Equal(t, int64(1617255900), actualLatestExchangeRateResponse.UpdateTime)
|
||||
}
|
||||
|
||||
func TestReserveBankOfAustraliaDataSource_StandardDataExtractExchangeRates(t *testing.T) {
|
||||
dataSource := &ReserveBankOfAustraliaDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte(reserveBankOfAustraliaMinimumRequiredContent))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Contains(t, actualLatestExchangeRateResponse.ExchangeRates, &models.LatestExchangeRate{
|
||||
Currency: "USD",
|
||||
Rate: "0.7543",
|
||||
})
|
||||
assert.Contains(t, actualLatestExchangeRateResponse.ExchangeRates, &models.LatestExchangeRate{
|
||||
Currency: "CNY",
|
||||
Rate: "4.9577",
|
||||
})
|
||||
}
|
||||
|
||||
func TestReserveBankOfAustraliaDataSource_BlankContent(t *testing.T) {
|
||||
dataSource := &ReserveBankOfAustraliaDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
_, err := dataSource.Parse(context, []byte(""))
|
||||
assert.NotEqual(t, nil, err)
|
||||
}
|
||||
|
||||
func TestReserveBankOfAustraliaDataSource_OnlyXMLHeader(t *testing.T) {
|
||||
dataSource := &ReserveBankOfAustraliaDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
_, err := dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"UTF-8\"?>"))
|
||||
assert.NotEqual(t, nil, err)
|
||||
}
|
||||
|
||||
func TestReserveBankOfAustraliaDataSource_EmptyRDFContent(t *testing.T) {
|
||||
dataSource := &ReserveBankOfAustraliaDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
_, err := dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"+
|
||||
"<rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\" xmlns:rba=\"https://www.rba.gov.au/statistics/frequency/exchange-rates.html\" xmlns:cb=\"http://www.cbwiki.net/wiki/index.php/Specification_1.2/\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\" xmlns:dcterms=\"http://purl.org/dc/terms/\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns=\"http://purl.org/rss/1.0/\" xsi:schemaLocation=\"http://www.w3.org/1999/02/22-rdf-syntax-ns# rdf.xsd\">\n"+
|
||||
"</rdf:RDF>"))
|
||||
assert.NotEqual(t, nil, err)
|
||||
}
|
||||
|
||||
func TestReserveBankOfAustraliaDataSource_EmptyChannelContent(t *testing.T) {
|
||||
dataSource := &ReserveBankOfAustraliaDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
_, err := dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"+
|
||||
"<rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\" xmlns:rba=\"https://www.rba.gov.au/statistics/frequency/exchange-rates.html\" xmlns:cb=\"http://www.cbwiki.net/wiki/index.php/Specification_1.2/\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\" xmlns:dcterms=\"http://purl.org/dc/terms/\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns=\"http://purl.org/rss/1.0/\" xsi:schemaLocation=\"http://www.w3.org/1999/02/22-rdf-syntax-ns# rdf.xsd\">\n"+
|
||||
" <channel rdf:about=\"https://www.rba.gov.au/rss/rss-cb-exchange-rates.xml\">\n"+
|
||||
" </channel>"+
|
||||
" <item rdf:about=\"https://www.rba.gov.au/statistics/frequency/exchange-rates.html#USD\">\n"+
|
||||
" <cb:statistics rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:exchangeRate rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:observation rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:value>0.7543</cb:value>\n"+
|
||||
" <cb:unit>AUD</cb:unit>\n"+
|
||||
" </cb:observation>\n"+
|
||||
" <cb:baseCurrency>AUD</cb:baseCurrency>\n"+
|
||||
" <cb:targetCurrency>USD</cb:targetCurrency>\n"+
|
||||
" </cb:exchangeRate>\n"+
|
||||
" </cb:statistics>\n"+
|
||||
" </item>\n"+
|
||||
"</rdf:RDF>"))
|
||||
assert.NotEqual(t, nil, err)
|
||||
}
|
||||
|
||||
func TestReserveBankOfAustraliaDataSource_NoItem(t *testing.T) {
|
||||
dataSource := &ReserveBankOfAustraliaDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
_, err := dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"+
|
||||
"<rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\" xmlns:rba=\"https://www.rba.gov.au/statistics/frequency/exchange-rates.html\" xmlns:cb=\"http://www.cbwiki.net/wiki/index.php/Specification_1.2/\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\" xmlns:dcterms=\"http://purl.org/dc/terms/\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns=\"http://purl.org/rss/1.0/\" xsi:schemaLocation=\"http://www.w3.org/1999/02/22-rdf-syntax-ns# rdf.xsd\">\n"+
|
||||
" <channel rdf:about=\"https://www.rba.gov.au/rss/rss-cb-exchange-rates.xml\">\n"+
|
||||
" <dc:date>2021-04-01T16:45:00+11:00</dc:date>\n"+
|
||||
" </channel>\n"+
|
||||
"</rdf:RDF>"))
|
||||
assert.NotEqual(t, nil, err)
|
||||
}
|
||||
|
||||
func TestReserveBankOfAustraliaDataSource_BaseCurrencyNotEqualPreset(t *testing.T) {
|
||||
dataSource := &ReserveBankOfAustraliaDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"+
|
||||
"<rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\" xmlns:rba=\"https://www.rba.gov.au/statistics/frequency/exchange-rates.html\" xmlns:cb=\"http://www.cbwiki.net/wiki/index.php/Specification_1.2/\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\" xmlns:dcterms=\"http://purl.org/dc/terms/\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns=\"http://purl.org/rss/1.0/\" xsi:schemaLocation=\"http://www.w3.org/1999/02/22-rdf-syntax-ns# rdf.xsd\">\n"+
|
||||
" <channel rdf:about=\"https://www.rba.gov.au/rss/rss-cb-exchange-rates.xml\">\n"+
|
||||
" <dc:date>2021-04-01T16:45:00+11:00</dc:date>\n"+
|
||||
" </channel>\n"+
|
||||
" <item rdf:about=\"https://www.rba.gov.au/statistics/frequency/exchange-rates.html#USD\">\n"+
|
||||
" <cb:statistics rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:exchangeRate rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:observation rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:value>0.7543</cb:value>\n"+
|
||||
" <cb:unit>AUD</cb:unit>\n"+
|
||||
" </cb:observation>\n"+
|
||||
" <cb:baseCurrency>USD</cb:baseCurrency>\n"+
|
||||
" <cb:targetCurrency>USD</cb:targetCurrency>\n"+
|
||||
" </cb:exchangeRate>\n"+
|
||||
" </cb:statistics>\n"+
|
||||
" </item>\n"+
|
||||
"</rdf:RDF>"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
}
|
||||
|
||||
func TestReserveBankOfAustraliaDataSource_UnitCurrencyNotEqualPreset(t *testing.T) {
|
||||
dataSource := &ReserveBankOfAustraliaDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"+
|
||||
"<rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\" xmlns:rba=\"https://www.rba.gov.au/statistics/frequency/exchange-rates.html\" xmlns:cb=\"http://www.cbwiki.net/wiki/index.php/Specification_1.2/\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\" xmlns:dcterms=\"http://purl.org/dc/terms/\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns=\"http://purl.org/rss/1.0/\" xsi:schemaLocation=\"http://www.w3.org/1999/02/22-rdf-syntax-ns# rdf.xsd\">\n"+
|
||||
" <channel rdf:about=\"https://www.rba.gov.au/rss/rss-cb-exchange-rates.xml\">\n"+
|
||||
" <dc:date>2021-04-01T16:45:00+11:00</dc:date>\n"+
|
||||
" </channel>\n"+
|
||||
" <item rdf:about=\"https://www.rba.gov.au/statistics/frequency/exchange-rates.html#USD\">\n"+
|
||||
" <cb:statistics rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:exchangeRate rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:observation rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:value>0.7543</cb:value>\n"+
|
||||
" <cb:unit>USD</cb:unit>\n"+
|
||||
" </cb:observation>\n"+
|
||||
" <cb:baseCurrency>AUD</cb:baseCurrency>\n"+
|
||||
" <cb:targetCurrency>USD</cb:targetCurrency>\n"+
|
||||
" </cb:exchangeRate>\n"+
|
||||
" </cb:statistics>\n"+
|
||||
" </item>\n"+
|
||||
"</rdf:RDF>"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
}
|
||||
|
||||
func TestReserveBankOfAustraliaDataSource_InvalidCurrency(t *testing.T) {
|
||||
dataSource := &ReserveBankOfAustraliaDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"+
|
||||
"<rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\" xmlns:rba=\"https://www.rba.gov.au/statistics/frequency/exchange-rates.html\" xmlns:cb=\"http://www.cbwiki.net/wiki/index.php/Specification_1.2/\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\" xmlns:dcterms=\"http://purl.org/dc/terms/\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns=\"http://purl.org/rss/1.0/\" xsi:schemaLocation=\"http://www.w3.org/1999/02/22-rdf-syntax-ns# rdf.xsd\">\n"+
|
||||
" <channel rdf:about=\"https://www.rba.gov.au/rss/rss-cb-exchange-rates.xml\">\n"+
|
||||
" <dc:date>2021-04-01T16:45:00+11:00</dc:date>\n"+
|
||||
" </channel>\n"+
|
||||
" <item rdf:about=\"https://www.rba.gov.au/statistics/frequency/exchange-rates.html#USD\">\n"+
|
||||
" <cb:statistics rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:exchangeRate rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:observation rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:value>1</cb:value>\n"+
|
||||
" <cb:unit>AUD</cb:unit>\n"+
|
||||
" </cb:observation>\n"+
|
||||
" <cb:baseCurrency>AUD</cb:baseCurrency>\n"+
|
||||
" <cb:targetCurrency>XXX</cb:targetCurrency>\n"+
|
||||
" </cb:exchangeRate>\n"+
|
||||
" </cb:statistics>\n"+
|
||||
" </item>\n"+
|
||||
"</rdf:RDF>"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
}
|
||||
|
||||
func TestReserveBankOfAustraliaDataSource_EmptyRate(t *testing.T) {
|
||||
dataSource := &ReserveBankOfAustraliaDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"+
|
||||
"<rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\" xmlns:rba=\"https://www.rba.gov.au/statistics/frequency/exchange-rates.html\" xmlns:cb=\"http://www.cbwiki.net/wiki/index.php/Specification_1.2/\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\" xmlns:dcterms=\"http://purl.org/dc/terms/\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns=\"http://purl.org/rss/1.0/\" xsi:schemaLocation=\"http://www.w3.org/1999/02/22-rdf-syntax-ns# rdf.xsd\">\n"+
|
||||
" <channel rdf:about=\"https://www.rba.gov.au/rss/rss-cb-exchange-rates.xml\">\n"+
|
||||
" <dc:date>2021-04-01T16:45:00+11:00</dc:date>\n"+
|
||||
" </channel>\n"+
|
||||
" <item rdf:about=\"https://www.rba.gov.au/statistics/frequency/exchange-rates.html#USD\">\n"+
|
||||
" <cb:statistics rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:exchangeRate rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:observation rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:value></cb:value>\n"+
|
||||
" <cb:unit>AUD</cb:unit>\n"+
|
||||
" </cb:observation>\n"+
|
||||
" <cb:baseCurrency>AUD</cb:baseCurrency>\n"+
|
||||
" <cb:targetCurrency>USD</cb:targetCurrency>\n"+
|
||||
" </cb:exchangeRate>\n"+
|
||||
" </cb:statistics>\n"+
|
||||
" </item>\n"+
|
||||
"</rdf:RDF>"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
}
|
||||
|
||||
func TestReserveBankOfAustraliaDataSource_InvalidRate(t *testing.T) {
|
||||
dataSource := &ReserveBankOfAustraliaDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"+
|
||||
"<rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\" xmlns:rba=\"https://www.rba.gov.au/statistics/frequency/exchange-rates.html\" xmlns:cb=\"http://www.cbwiki.net/wiki/index.php/Specification_1.2/\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\" xmlns:dcterms=\"http://purl.org/dc/terms/\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns=\"http://purl.org/rss/1.0/\" xsi:schemaLocation=\"http://www.w3.org/1999/02/22-rdf-syntax-ns# rdf.xsd\">\n"+
|
||||
" <channel rdf:about=\"https://www.rba.gov.au/rss/rss-cb-exchange-rates.xml\">\n"+
|
||||
" <dc:date>2021-04-01T16:45:00+11:00</dc:date>\n"+
|
||||
" </channel>\n"+
|
||||
" <item rdf:about=\"https://www.rba.gov.au/statistics/frequency/exchange-rates.html#USD\">\n"+
|
||||
" <cb:statistics rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:exchangeRate rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:observation rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:value>null</cb:value>\n"+
|
||||
" <cb:unit>AUD</cb:unit>\n"+
|
||||
" </cb:observation>\n"+
|
||||
" <cb:baseCurrency>AUD</cb:baseCurrency>\n"+
|
||||
" <cb:targetCurrency>USD</cb:targetCurrency>\n"+
|
||||
" </cb:exchangeRate>\n"+
|
||||
" </cb:statistics>\n"+
|
||||
" </item>\n"+
|
||||
"</rdf:RDF>"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
}
|
||||
@@ -5,7 +5,9 @@ import (
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/llm/data"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/llm/provider"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/llm/provider/anthropic"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/llm/provider/googleai"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/llm/provider/lmstudio"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/llm/provider/ollama"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/llm/provider/openai"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
||||
@@ -41,10 +43,16 @@ func initializeLargeLanguageModelProvider(llmConfig *settings.LLMConfig, enableR
|
||||
return openai.NewOpenAILargeLanguageModelProvider(llmConfig, enableResponseLog), nil
|
||||
} else if llmConfig.LLMProvider == settings.OpenAICompatibleLLMProvider {
|
||||
return openai.NewOpenAICompatibleLargeLanguageModelProvider(llmConfig, enableResponseLog), nil
|
||||
} else if llmConfig.LLMProvider == settings.AnthropicLLMProvider {
|
||||
return anthropic.NewAnthropicLargeLanguageModelProvider(llmConfig, enableResponseLog), nil
|
||||
} else if llmConfig.LLMProvider == settings.AnthropicCompatibleLLMProvider {
|
||||
return anthropic.NewAnthropicCompatibleLargeLanguageModelProvider(llmConfig, enableResponseLog), nil
|
||||
} else if llmConfig.LLMProvider == settings.OpenRouterLLMProvider {
|
||||
return openai.NewOpenRouterLargeLanguageModelProvider(llmConfig, enableResponseLog), nil
|
||||
} else if llmConfig.LLMProvider == settings.OllamaLLMProvider {
|
||||
return ollama.NewOllamaLargeLanguageModelProvider(llmConfig, enableResponseLog), nil
|
||||
} else if llmConfig.LLMProvider == settings.LMStudioLLMProvider {
|
||||
return lmstudio.NewLMStudioLargeLanguageModelProvider(llmConfig, enableResponseLog), nil
|
||||
} else if llmConfig.LLMProvider == settings.GoogleAILLMProvider {
|
||||
return googleai.NewGoogleAILargeLanguageModelProvider(llmConfig, enableResponseLog), nil
|
||||
} else if llmConfig.LLMProvider == "" {
|
||||
|
||||
+196
@@ -0,0 +1,196 @@
|
||||
package anthropic
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/llm/data"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/llm/provider"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/llm/provider/common"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
||||
)
|
||||
|
||||
// AnthropicMessagesAPIProvider defines the structure of Anthropic messages API provider
|
||||
type AnthropicMessagesAPIProvider interface {
|
||||
// BuildMessagesHttpRequest returns the messages http request
|
||||
BuildMessagesHttpRequest(c core.Context, uid int64) (*http.Request, error)
|
||||
|
||||
// GetModelID returns the model id
|
||||
GetModelID() string
|
||||
|
||||
// GetMaxTokens returns the max tokens to generate
|
||||
GetMaxTokens() uint32
|
||||
}
|
||||
|
||||
// CommonAnthropicMessagesAPILargeLanguageModelAdapter defines the structure of Anthropic common compatible large language model adapter based on messages api
|
||||
type CommonAnthropicMessagesAPILargeLanguageModelAdapter struct {
|
||||
common.HttpLargeLanguageModelAdapter
|
||||
apiProvider AnthropicMessagesAPIProvider
|
||||
}
|
||||
|
||||
// AnthropicMessageRole defines the role of Anthropic message
|
||||
type AnthropicMessageRole string
|
||||
|
||||
// Anthropic Message Roles
|
||||
const (
|
||||
AnthropicMessageRoleUser AnthropicMessageRole = "user"
|
||||
)
|
||||
|
||||
type AnthropicThinkingType string
|
||||
|
||||
// Anthropic Thinking Types
|
||||
const (
|
||||
AnthropicThinkingTypeDisabled AnthropicThinkingType = "disabled"
|
||||
)
|
||||
|
||||
// AnthropicMessagesRequest defines the structure of Anthropic messages request
|
||||
type AnthropicMessagesRequest struct {
|
||||
Model string `json:"model"`
|
||||
MaxTokens uint32 `json:"max_tokens"`
|
||||
Stream bool `json:"stream"`
|
||||
System string `json:"system,omitempty"`
|
||||
Messages []any `json:"messages"`
|
||||
Thinking *AnthropicMessagesRequestThinkingConfigParam `json:"thinking,omitempty"`
|
||||
}
|
||||
|
||||
// AnthropicMessagesRequestMessage defines the structure of Anthropic messages request message
|
||||
type AnthropicMessagesRequestMessage[T string | []*AnthropicMessagesRequestImageBlockParam] struct {
|
||||
Role AnthropicMessageRole `json:"role"`
|
||||
Content T `json:"content"`
|
||||
}
|
||||
|
||||
// AnthropicMessagesRequestImageBlockParam defines the structure of Anthropic messages request image content block param
|
||||
type AnthropicMessagesRequestImageBlockParam struct {
|
||||
Source *AnthropicMessagesRequestBase64ImageSource `json:"source"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
// AnthropicMessagesRequestBase64ImageSource defines the structure of Anthropic messages request base64 image source
|
||||
type AnthropicMessagesRequestBase64ImageSource struct {
|
||||
Data string `json:"data"`
|
||||
MediaType string `json:"media_type"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
// AnthropicMessagesRequestThinkingConfigParam defines the structure of Anthropic messages request thinking config param
|
||||
type AnthropicMessagesRequestThinkingConfigParam struct {
|
||||
Type AnthropicThinkingType `json:"type"`
|
||||
}
|
||||
|
||||
// AnthropicMessagesResponse defines the structure of Anthropic messages response
|
||||
type AnthropicMessagesResponse struct {
|
||||
Content []*AnthropicMessagesResponseContentBlock `json:"content"`
|
||||
}
|
||||
|
||||
// AnthropicMessagesResponseContentBlock defines the structure of Anthropic messages response content block
|
||||
type AnthropicMessagesResponseContentBlock struct {
|
||||
Text *string `json:"text"`
|
||||
}
|
||||
|
||||
// BuildTextualRequest returns the http request by Anthropic common compatible adapter
|
||||
func (p *CommonAnthropicMessagesAPILargeLanguageModelAdapter) BuildTextualRequest(c core.Context, uid int64, request *data.LargeLanguageModelRequest, responseType data.LargeLanguageModelResponseFormat) (*http.Request, error) {
|
||||
requestBody, err := p.buildJsonRequestBody(c, uid, request, responseType)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
httpRequest, err := p.apiProvider.BuildMessagesHttpRequest(c, uid)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
httpRequest.Body = io.NopCloser(bytes.NewReader(requestBody))
|
||||
httpRequest.Header.Set("Content-Type", "application/json")
|
||||
|
||||
return httpRequest, nil
|
||||
}
|
||||
|
||||
// ParseTextualResponse returns the textual response by Anthropic common compatible adapter
|
||||
func (p *CommonAnthropicMessagesAPILargeLanguageModelAdapter) ParseTextualResponse(c core.Context, uid int64, body []byte, responseType data.LargeLanguageModelResponseFormat) (*data.LargeLanguageModelTextualResponse, error) {
|
||||
messagesResponse := &AnthropicMessagesResponse{}
|
||||
err := json.Unmarshal(body, &messagesResponse)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[anthropic_common_compatible_large_language_model_adapter.ParseTextualResponse] failed to parse messages response for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.ErrFailedToRequestRemoteApi
|
||||
}
|
||||
|
||||
if messagesResponse == nil || messagesResponse.Content == nil || len(messagesResponse.Content) < 1 || messagesResponse.Content[0].Text == nil {
|
||||
log.Errorf(c, "[anthropic_common_compatible_large_language_model_adapter.ParseTextualResponse] messages response is invalid for user \"uid:%d\"", uid)
|
||||
return nil, errs.ErrFailedToRequestRemoteApi
|
||||
}
|
||||
|
||||
textualResponse := &data.LargeLanguageModelTextualResponse{
|
||||
Content: *messagesResponse.Content[0].Text,
|
||||
}
|
||||
|
||||
return textualResponse, nil
|
||||
}
|
||||
|
||||
func (p *CommonAnthropicMessagesAPILargeLanguageModelAdapter) buildJsonRequestBody(c core.Context, uid int64, request *data.LargeLanguageModelRequest, responseType data.LargeLanguageModelResponseFormat) ([]byte, error) {
|
||||
if p.apiProvider.GetModelID() == "" {
|
||||
return nil, errs.ErrInvalidLLMModelId
|
||||
}
|
||||
|
||||
messagesRequest := &AnthropicMessagesRequest{
|
||||
Model: p.apiProvider.GetModelID(),
|
||||
MaxTokens: p.apiProvider.GetMaxTokens(),
|
||||
Stream: request.Stream,
|
||||
Messages: make([]any, 0, 1),
|
||||
Thinking: &AnthropicMessagesRequestThinkingConfigParam{
|
||||
Type: AnthropicThinkingTypeDisabled,
|
||||
},
|
||||
}
|
||||
|
||||
if request.SystemPrompt != "" {
|
||||
messagesRequest.System = request.SystemPrompt
|
||||
}
|
||||
|
||||
if len(request.UserPrompt) > 0 {
|
||||
if request.UserPromptType == data.LARGE_LANGUAGE_MODEL_REQUEST_PROMPT_TYPE_IMAGE_URL {
|
||||
imageBase64Data := base64.StdEncoding.EncodeToString(request.UserPrompt)
|
||||
messagesRequest.Messages = append(messagesRequest.Messages, &AnthropicMessagesRequestMessage[[]*AnthropicMessagesRequestImageBlockParam]{
|
||||
Role: AnthropicMessageRoleUser,
|
||||
Content: []*AnthropicMessagesRequestImageBlockParam{
|
||||
{
|
||||
Type: "image",
|
||||
Source: &AnthropicMessagesRequestBase64ImageSource{
|
||||
Data: imageBase64Data,
|
||||
MediaType: request.UserPromptContentType,
|
||||
Type: "base64",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
} else {
|
||||
messagesRequest.Messages = append(messagesRequest.Messages, &AnthropicMessagesRequestMessage[string]{
|
||||
Role: AnthropicMessageRoleUser,
|
||||
Content: string(request.UserPrompt),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
requestBodyBytes, err := json.Marshal(messagesRequest)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[anthropic_common_compatible_large_language_model_adapter.buildJsonRequestBody] failed to marshal request body for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.ErrOperationFailed
|
||||
}
|
||||
|
||||
log.Debugf(c, "[anthropic_common_compatible_large_language_model_adapter.buildJsonRequestBody] request body is %s", requestBodyBytes)
|
||||
return requestBodyBytes, nil
|
||||
}
|
||||
|
||||
func newCommonAnthropicMessagesAPILargeLanguageModelAdapter(llmConfig *settings.LLMConfig, enableResponseLog bool, apiProvider AnthropicMessagesAPIProvider) provider.LargeLanguageModelProvider {
|
||||
return common.NewCommonHttpLargeLanguageModelProvider(llmConfig, enableResponseLog, &CommonAnthropicMessagesAPILargeLanguageModelAdapter{
|
||||
apiProvider: apiProvider,
|
||||
})
|
||||
}
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
package anthropic
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/llm/data"
|
||||
)
|
||||
|
||||
func TestCommonAnthropicMessagesAPILargeLanguageModelAdapter_buildJsonRequestBody_TextualUserPrompt(t *testing.T) {
|
||||
adapter := &CommonAnthropicMessagesAPILargeLanguageModelAdapter{
|
||||
apiProvider: &AnthropicOfficialMessagesAPIProvider{
|
||||
AnthropicModelID: "test",
|
||||
AnthropicMaxTokens: 128,
|
||||
},
|
||||
}
|
||||
|
||||
request := &data.LargeLanguageModelRequest{
|
||||
SystemPrompt: "You are a helpful assistant.",
|
||||
UserPrompt: []byte("Hello, how are you?"),
|
||||
}
|
||||
|
||||
bodyBytes, err := adapter.buildJsonRequestBody(core.NewNullContext(), 0, request, data.LARGE_LANGUAGE_MODEL_RESPONSE_FORMAT_JSON)
|
||||
assert.Nil(t, err)
|
||||
|
||||
var body map[string]interface{}
|
||||
err = json.Unmarshal(bodyBytes, &body)
|
||||
assert.Nil(t, err)
|
||||
|
||||
assert.Equal(t, "{\"model\":\"test\",\"max_tokens\":128,\"stream\":false,\"system\":\"You are a helpful assistant.\",\"messages\":[{\"role\":\"user\",\"content\":\"Hello, how are you?\"}],\"thinking\":{\"type\":\"disabled\"}}", string(bodyBytes))
|
||||
}
|
||||
|
||||
func TestCommonAnthropicMessagesAPILargeLanguageModelAdapter_buildJsonRequestBody_ImageUserPrompt(t *testing.T) {
|
||||
adapter := &CommonAnthropicMessagesAPILargeLanguageModelAdapter{
|
||||
apiProvider: &AnthropicOfficialMessagesAPIProvider{
|
||||
AnthropicModelID: "test",
|
||||
AnthropicMaxTokens: 128,
|
||||
},
|
||||
}
|
||||
|
||||
request := &data.LargeLanguageModelRequest{
|
||||
SystemPrompt: "What's in this image?",
|
||||
UserPrompt: []byte("fakedata"),
|
||||
UserPromptType: data.LARGE_LANGUAGE_MODEL_REQUEST_PROMPT_TYPE_IMAGE_URL,
|
||||
UserPromptContentType: "image/png",
|
||||
}
|
||||
|
||||
bodyBytes, err := adapter.buildJsonRequestBody(core.NewNullContext(), 0, request, data.LARGE_LANGUAGE_MODEL_RESPONSE_FORMAT_JSON)
|
||||
assert.Nil(t, err)
|
||||
|
||||
var body map[string]interface{}
|
||||
err = json.Unmarshal(bodyBytes, &body)
|
||||
assert.Nil(t, err)
|
||||
|
||||
assert.Equal(t, "{\"model\":\"test\",\"max_tokens\":128,\"stream\":false,\"system\":\"What's in this image?\",\"messages\":[{\"role\":\"user\",\"content\":[{\"source\":{\"data\":\"ZmFrZWRhdGE=\",\"media_type\":\"image/png\",\"type\":\"base64\"},\"type\":\"image\"}]}],\"thinking\":{\"type\":\"disabled\"}}", string(bodyBytes))
|
||||
}
|
||||
|
||||
func TestCommonAnthropicMessagesAPILargeLanguageModelAdapter_ParseTextualResponse_ValidJsonResponse(t *testing.T) {
|
||||
adapter := &CommonAnthropicMessagesAPILargeLanguageModelAdapter{
|
||||
apiProvider: &AnthropicOfficialMessagesAPIProvider{},
|
||||
}
|
||||
|
||||
response := `{
|
||||
"id": "test-123",
|
||||
"role": "assistant",
|
||||
"type": "message",
|
||||
"model": "test",
|
||||
"usage": {
|
||||
"input_tokens": 13,
|
||||
"output_tokens": 7
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "This is a test response"
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
result, err := adapter.ParseTextualResponse(core.NewNullContext(), 0, []byte(response), data.LARGE_LANGUAGE_MODEL_RESPONSE_FORMAT_JSON)
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, "This is a test response", result.Content)
|
||||
}
|
||||
|
||||
func TestCommonAnthropicMessagesAPILargeLanguageModelAdapter_ParseTextualResponse_EmptyContentText(t *testing.T) {
|
||||
adapter := &CommonAnthropicMessagesAPILargeLanguageModelAdapter{
|
||||
apiProvider: &AnthropicOfficialMessagesAPIProvider{},
|
||||
}
|
||||
|
||||
response := `{
|
||||
"id": "test-123",
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": ""
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
result, err := adapter.ParseTextualResponse(core.NewNullContext(), 0, []byte(response), data.LARGE_LANGUAGE_MODEL_RESPONSE_FORMAT_JSON)
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, "", result.Content)
|
||||
}
|
||||
|
||||
func TestCommonAnthropicMessagesAPILargeLanguageModelAdapter_ParseTextualResponse_EmptyContent(t *testing.T) {
|
||||
adapter := &CommonAnthropicMessagesAPILargeLanguageModelAdapter{
|
||||
apiProvider: &AnthropicOfficialMessagesAPIProvider{},
|
||||
}
|
||||
|
||||
response := `{
|
||||
"id": "test-123",
|
||||
"role": "assistant",
|
||||
"content": []
|
||||
}`
|
||||
|
||||
_, err := adapter.ParseTextualResponse(core.NewNullContext(), 0, []byte(response), data.LARGE_LANGUAGE_MODEL_RESPONSE_FORMAT_JSON)
|
||||
assert.EqualError(t, err, "failed to request third party api")
|
||||
}
|
||||
|
||||
func TestCommonAnthropicMessagesAPILargeLanguageModelAdapter_ParseTextualResponse_NoContentText(t *testing.T) {
|
||||
adapter := &CommonAnthropicMessagesAPILargeLanguageModelAdapter{
|
||||
apiProvider: &AnthropicOfficialMessagesAPIProvider{},
|
||||
}
|
||||
|
||||
response := `{
|
||||
"id": "msg_123",
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{
|
||||
"type": "text"
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
_, err := adapter.ParseTextualResponse(core.NewNullContext(), 0, []byte(response), data.LARGE_LANGUAGE_MODEL_RESPONSE_FORMAT_JSON)
|
||||
assert.EqualError(t, err, "failed to request third party api")
|
||||
}
|
||||
|
||||
func TestCommonAnthropicMessagesAPILargeLanguageModelAdapter_ParseTextualResponse_InvalidJson(t *testing.T) {
|
||||
adapter := &CommonAnthropicMessagesAPILargeLanguageModelAdapter{
|
||||
apiProvider: &AnthropicOfficialMessagesAPIProvider{},
|
||||
}
|
||||
|
||||
response := "error"
|
||||
|
||||
_, err := adapter.ParseTextualResponse(core.NewNullContext(), 0, []byte(response), data.LARGE_LANGUAGE_MODEL_RESPONSE_FORMAT_JSON)
|
||||
assert.EqualError(t, err, "failed to request third party api")
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package anthropic
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/llm/provider"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
||||
)
|
||||
|
||||
const anthropicCompatibleMessagesPath = "messages"
|
||||
|
||||
// AnthropicCompatibleMessagesAPIProvider defines the structure of Anthropic compatible messages API provider
|
||||
type AnthropicCompatibleMessagesAPIProvider struct {
|
||||
AnthropicMessagesAPIProvider
|
||||
AnthropicCompatibleBaseURL string
|
||||
AnthropicCompatibleAPIVersion string
|
||||
AnthropicCompatibleAPIKey string
|
||||
AnthropicCompatibleModelID string
|
||||
AnthropicCompatibleMaxTokens uint32
|
||||
}
|
||||
|
||||
// BuildMessagesHttpRequest returns the messages http request by Anthropic compatible messages API provider
|
||||
func (p *AnthropicCompatibleMessagesAPIProvider) BuildMessagesHttpRequest(c core.Context, uid int64) (*http.Request, error) {
|
||||
req, err := http.NewRequest("POST", p.getFinalMessagesRequestUrl(), nil)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if p.AnthropicCompatibleAPIVersion != "" {
|
||||
req.Header.Set("anthropic-version", p.AnthropicCompatibleAPIVersion)
|
||||
}
|
||||
|
||||
if p.AnthropicCompatibleAPIKey != "" {
|
||||
req.Header.Set("X-Api-Key", p.AnthropicCompatibleAPIKey)
|
||||
}
|
||||
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// GetModelID returns the model id of Anthropic compatible messages API provider
|
||||
func (p *AnthropicCompatibleMessagesAPIProvider) GetModelID() string {
|
||||
return p.AnthropicCompatibleModelID
|
||||
}
|
||||
|
||||
// GetMaxTokens returns the max tokens to generate of Anthropic compatible messages API provider
|
||||
func (p *AnthropicCompatibleMessagesAPIProvider) GetMaxTokens() uint32 {
|
||||
return p.AnthropicCompatibleMaxTokens
|
||||
}
|
||||
|
||||
func (p *AnthropicCompatibleMessagesAPIProvider) getFinalMessagesRequestUrl() string {
|
||||
url := p.AnthropicCompatibleBaseURL
|
||||
|
||||
if url[len(url)-1] != '/' {
|
||||
url += "/"
|
||||
}
|
||||
|
||||
url += anthropicCompatibleMessagesPath
|
||||
return url
|
||||
}
|
||||
|
||||
// NewAnthropicCompatibleLargeLanguageModelProvider creates a new Anthropic compatible large language model provider instance
|
||||
func NewAnthropicCompatibleLargeLanguageModelProvider(llmConfig *settings.LLMConfig, enableResponseLog bool) provider.LargeLanguageModelProvider {
|
||||
return newCommonAnthropicMessagesAPILargeLanguageModelAdapter(llmConfig, enableResponseLog, &AnthropicCompatibleMessagesAPIProvider{
|
||||
AnthropicCompatibleBaseURL: llmConfig.AnthropicCompatibleBaseURL,
|
||||
AnthropicCompatibleAPIVersion: llmConfig.AnthropicCompatibleAPIVersion,
|
||||
AnthropicCompatibleAPIKey: llmConfig.AnthropicCompatibleAPIKey,
|
||||
AnthropicCompatibleModelID: llmConfig.AnthropicCompatibleModelID,
|
||||
AnthropicCompatibleMaxTokens: llmConfig.AnthropicCompatibleMaxTokens,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package anthropic
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestAnthropicCompatibleMessagesAPIProvider_GetFinalRequestUrl(t *testing.T) {
|
||||
apiProvider := &AnthropicCompatibleMessagesAPIProvider{
|
||||
AnthropicCompatibleBaseURL: "https://api.example.com/v1/",
|
||||
}
|
||||
url := apiProvider.getFinalMessagesRequestUrl()
|
||||
assert.Equal(t, "https://api.example.com/v1/messages", url)
|
||||
|
||||
apiProvider = &AnthropicCompatibleMessagesAPIProvider{
|
||||
AnthropicCompatibleBaseURL: "https://api.example.com/v1",
|
||||
}
|
||||
url = apiProvider.getFinalMessagesRequestUrl()
|
||||
assert.Equal(t, "https://api.example.com/v1/messages", url)
|
||||
|
||||
apiProvider = &AnthropicCompatibleMessagesAPIProvider{
|
||||
AnthropicCompatibleBaseURL: "https://example.com/api",
|
||||
}
|
||||
url = apiProvider.getFinalMessagesRequestUrl()
|
||||
assert.Equal(t, "https://example.com/api/messages", url)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package anthropic
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/llm/provider"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
||||
)
|
||||
|
||||
// AnthropicOfficialMessagesAPIProvider defines the structure of Anthropic official messages API provider
|
||||
type AnthropicOfficialMessagesAPIProvider struct {
|
||||
AnthropicMessagesAPIProvider
|
||||
AnthropicAPIKey string
|
||||
AnthropicModelID string
|
||||
AnthropicMaxTokens uint32
|
||||
}
|
||||
|
||||
const anthropicMessagesUrl = "https://api.anthropic.com/v1/messages"
|
||||
const anthropicAPIVersion = "2023-06-01"
|
||||
|
||||
// BuildMessagesHttpRequest returns the messages http request by Anthropic official messages API provider
|
||||
func (p *AnthropicOfficialMessagesAPIProvider) BuildMessagesHttpRequest(c core.Context, uid int64) (*http.Request, error) {
|
||||
req, err := http.NewRequest("POST", anthropicMessagesUrl, nil)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("anthropic-version", anthropicAPIVersion)
|
||||
req.Header.Set("X-Api-Key", p.AnthropicAPIKey)
|
||||
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// GetModelID returns the model id of Anthropic official messages API provider
|
||||
func (p *AnthropicOfficialMessagesAPIProvider) GetModelID() string {
|
||||
return p.AnthropicModelID
|
||||
}
|
||||
|
||||
// GetMaxTokens returns the max tokens to generate of Anthropic official messages API provider
|
||||
func (p *AnthropicOfficialMessagesAPIProvider) GetMaxTokens() uint32 {
|
||||
return p.AnthropicMaxTokens
|
||||
}
|
||||
|
||||
// NewAnthropicLargeLanguageModelProvider creates a new Anthropic large language model provider instance
|
||||
func NewAnthropicLargeLanguageModelProvider(llmConfig *settings.LLMConfig, enableResponseLog bool) provider.LargeLanguageModelProvider {
|
||||
return newCommonAnthropicMessagesAPILargeLanguageModelAdapter(llmConfig, enableResponseLog, &AnthropicOfficialMessagesAPIProvider{
|
||||
AnthropicAPIKey: llmConfig.AnthropicAPIKey,
|
||||
AnthropicModelID: llmConfig.AnthropicModelID,
|
||||
AnthropicMaxTokens: llmConfig.AnthropicMaxTokens,
|
||||
})
|
||||
}
|
||||
@@ -83,6 +83,6 @@ func (p *CommonHttpLargeLanguageModelProvider) getTextualResponse(c core.Context
|
||||
func NewCommonHttpLargeLanguageModelProvider(llmConfig *settings.LLMConfig, enableResponseLog bool, adapter HttpLargeLanguageModelAdapter) *CommonHttpLargeLanguageModelProvider {
|
||||
return &CommonHttpLargeLanguageModelProvider{
|
||||
adapter: adapter,
|
||||
httpClient: httpclient.NewHttpClient(llmConfig.LargeLanguageModelAPIRequestTimeout, llmConfig.LargeLanguageModelAPIProxy, llmConfig.LargeLanguageModelAPISkipTLSVerify, settings.GetUserAgent(), enableResponseLog),
|
||||
httpClient: httpclient.NewHttpClient(llmConfig.LargeLanguageModelAPIRequestTimeout, llmConfig.LargeLanguageModelAPIProxy, llmConfig.LargeLanguageModelAPISkipTLSVerify, core.GetOutgoingUserAgent(), enableResponseLog),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
package lmstudio
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/llm/data"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/llm/provider"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/llm/provider/common"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
||||
)
|
||||
|
||||
const lmStudioChatPath = "api/v1/chat"
|
||||
|
||||
// LMStudioLargeLanguageModelAdapter defines the structure of LM Studio large language model adapter
|
||||
type LMStudioLargeLanguageModelAdapter struct {
|
||||
common.HttpLargeLanguageModelAdapter
|
||||
LMStudioServerURL string
|
||||
LMStudioToken string
|
||||
LMStudioModelID string
|
||||
}
|
||||
|
||||
// LMStudioChatRequest defines the structure of LM Studio chat request
|
||||
type LMStudioChatRequest struct {
|
||||
Model string `json:"model"`
|
||||
Stream bool `json:"stream"`
|
||||
SystemPrompt string `json:"system_prompt,omitempty"`
|
||||
Input []*LMStudioChatRequestInput `json:"input"`
|
||||
}
|
||||
|
||||
// LMStudioChatRequestInput defines the structure of LM Studio chat request message
|
||||
type LMStudioChatRequestInput struct {
|
||||
Type string `json:"type"`
|
||||
Content string `json:"content,omitempty"`
|
||||
DataUrl string `json:"data_url,omitempty"`
|
||||
}
|
||||
|
||||
// LMStudioChatResponse defines the structure of LM Studio chat response
|
||||
type LMStudioChatResponse struct {
|
||||
Output []*LMStudioChatResponseOutput `json:"output"`
|
||||
}
|
||||
|
||||
// LMStudioChatResponseOutput defines the structure of LM Studio chat response message
|
||||
type LMStudioChatResponseOutput struct {
|
||||
Content *string `json:"content"`
|
||||
}
|
||||
|
||||
// BuildTextualRequest returns the http request by LM Studio large language model adapter
|
||||
func (p *LMStudioLargeLanguageModelAdapter) BuildTextualRequest(c core.Context, uid int64, request *data.LargeLanguageModelRequest, responseType data.LargeLanguageModelResponseFormat) (*http.Request, error) {
|
||||
requestBody, err := p.buildJsonRequestBody(c, uid, request, responseType)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
httpRequest, err := http.NewRequest("POST", p.getLMStudioRequestUrl(), bytes.NewReader(requestBody))
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if p.LMStudioToken != "" {
|
||||
httpRequest.Header.Set("Authorization", "Bearer "+p.LMStudioToken)
|
||||
}
|
||||
|
||||
httpRequest.Header.Set("Content-Type", "application/json")
|
||||
|
||||
return httpRequest, nil
|
||||
}
|
||||
|
||||
// ParseTextualResponse returns the textual response by LM Studio large language model adapter
|
||||
func (p *LMStudioLargeLanguageModelAdapter) ParseTextualResponse(c core.Context, uid int64, body []byte, responseType data.LargeLanguageModelResponseFormat) (*data.LargeLanguageModelTextualResponse, error) {
|
||||
chatResponse := &LMStudioChatResponse{}
|
||||
err := json.Unmarshal(body, &chatResponse)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[lm_studio_large_language_model_adapter.ParseTextualResponse] failed to parse chat response for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.ErrFailedToRequestRemoteApi
|
||||
}
|
||||
|
||||
if chatResponse == nil || len(chatResponse.Output) < 1 || chatResponse.Output[0].Content == nil {
|
||||
log.Errorf(c, "[lm_studio_large_language_model_adapter.ParseTextualResponse] chat response is invalid for user \"uid:%d\"", uid)
|
||||
return nil, errs.ErrFailedToRequestRemoteApi
|
||||
}
|
||||
|
||||
textualResponse := &data.LargeLanguageModelTextualResponse{
|
||||
Content: *chatResponse.Output[0].Content,
|
||||
}
|
||||
|
||||
return textualResponse, nil
|
||||
}
|
||||
|
||||
func (p *LMStudioLargeLanguageModelAdapter) buildJsonRequestBody(c core.Context, uid int64, request *data.LargeLanguageModelRequest, responseType data.LargeLanguageModelResponseFormat) ([]byte, error) {
|
||||
if p.LMStudioModelID == "" {
|
||||
return nil, errs.ErrInvalidLLMModelId
|
||||
}
|
||||
|
||||
chatRequest := &LMStudioChatRequest{
|
||||
Model: p.LMStudioModelID,
|
||||
Stream: request.Stream,
|
||||
Input: make([]*LMStudioChatRequestInput, 0, 1),
|
||||
}
|
||||
|
||||
if request.SystemPrompt != "" {
|
||||
chatRequest.SystemPrompt = request.SystemPrompt
|
||||
}
|
||||
|
||||
if len(request.UserPrompt) > 0 {
|
||||
if request.UserPromptType == data.LARGE_LANGUAGE_MODEL_REQUEST_PROMPT_TYPE_IMAGE_URL {
|
||||
imageBase64Data := "data:" + request.UserPromptContentType + ";base64," + base64.StdEncoding.EncodeToString(request.UserPrompt)
|
||||
chatRequest.Input = append(chatRequest.Input, &LMStudioChatRequestInput{
|
||||
Type: "image",
|
||||
DataUrl: imageBase64Data,
|
||||
})
|
||||
} else {
|
||||
chatRequest.Input = append(chatRequest.Input, &LMStudioChatRequestInput{
|
||||
Type: "text",
|
||||
Content: string(request.UserPrompt),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
requestBodyBytes, err := json.Marshal(chatRequest)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[lm_studio_large_language_model_adapter.buildJsonRequestBody] failed to marshal request body for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.ErrOperationFailed
|
||||
}
|
||||
|
||||
log.Debugf(c, "[lm_studio_large_language_model_adapter.buildJsonRequestBody] request body is %s", requestBodyBytes)
|
||||
return requestBodyBytes, nil
|
||||
}
|
||||
|
||||
func (p *LMStudioLargeLanguageModelAdapter) getLMStudioRequestUrl() string {
|
||||
url := p.LMStudioServerURL
|
||||
|
||||
if url[len(url)-1] != '/' {
|
||||
url += "/"
|
||||
}
|
||||
|
||||
url += lmStudioChatPath
|
||||
return url
|
||||
}
|
||||
|
||||
// NewLMStudioLargeLanguageModelProvider creates a new LM Studio large language model provider instance
|
||||
func NewLMStudioLargeLanguageModelProvider(llmConfig *settings.LLMConfig, enableResponseLog bool) provider.LargeLanguageModelProvider {
|
||||
return common.NewCommonHttpLargeLanguageModelProvider(llmConfig, enableResponseLog, &LMStudioLargeLanguageModelAdapter{
|
||||
LMStudioServerURL: llmConfig.LMStudioServerURL,
|
||||
LMStudioToken: llmConfig.LMStudioToken,
|
||||
LMStudioModelID: llmConfig.LMStudioModelID,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package lmstudio
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/llm/data"
|
||||
)
|
||||
|
||||
func TestLMStudioLargeLanguageModelAdapter_buildJsonRequestBody_TextualUserPrompt(t *testing.T) {
|
||||
adapter := &LMStudioLargeLanguageModelAdapter{
|
||||
LMStudioModelID: "test",
|
||||
}
|
||||
|
||||
request := &data.LargeLanguageModelRequest{
|
||||
SystemPrompt: "You are a helpful assistant.",
|
||||
UserPrompt: []byte("Hello, how are you?"),
|
||||
}
|
||||
|
||||
bodyBytes, err := adapter.buildJsonRequestBody(core.NewNullContext(), 0, request, data.LARGE_LANGUAGE_MODEL_RESPONSE_FORMAT_JSON)
|
||||
assert.Nil(t, err)
|
||||
|
||||
var body map[string]interface{}
|
||||
err = json.Unmarshal(bodyBytes, &body)
|
||||
assert.Nil(t, err)
|
||||
|
||||
assert.Equal(t, "{\"model\":\"test\",\"stream\":false,\"system_prompt\":\"You are a helpful assistant.\",\"input\":[{\"type\":\"text\",\"content\":\"Hello, how are you?\"}]}", string(bodyBytes))
|
||||
}
|
||||
|
||||
func TestLMStudioLargeLanguageModelAdapter_buildJsonRequestBody_ImageUserPrompt(t *testing.T) {
|
||||
adapter := &LMStudioLargeLanguageModelAdapter{
|
||||
LMStudioModelID: "test",
|
||||
}
|
||||
|
||||
request := &data.LargeLanguageModelRequest{
|
||||
SystemPrompt: "What's in this image?",
|
||||
UserPrompt: []byte("fakedata"),
|
||||
UserPromptType: data.LARGE_LANGUAGE_MODEL_REQUEST_PROMPT_TYPE_IMAGE_URL,
|
||||
UserPromptContentType: "image/png",
|
||||
}
|
||||
|
||||
bodyBytes, err := adapter.buildJsonRequestBody(core.NewNullContext(), 0, request, data.LARGE_LANGUAGE_MODEL_RESPONSE_FORMAT_JSON)
|
||||
assert.Nil(t, err)
|
||||
|
||||
var body map[string]interface{}
|
||||
err = json.Unmarshal(bodyBytes, &body)
|
||||
assert.Nil(t, err)
|
||||
|
||||
assert.Equal(t, "{\"model\":\"test\",\"stream\":false,\"system_prompt\":\"What's in this image?\",\"input\":[{\"type\":\"image\",\"data_url\":\"data:image/png;base64,ZmFrZWRhdGE=\"}]}", string(bodyBytes))
|
||||
}
|
||||
|
||||
func TestLMStudioLargeLanguageModelAdapter_ParseTextualResponse_ValidJsonResponse(t *testing.T) {
|
||||
adapter := &LMStudioLargeLanguageModelAdapter{}
|
||||
|
||||
response := `{
|
||||
"model_instance_id": "test",
|
||||
"output": [
|
||||
{
|
||||
"type": "message",
|
||||
"content": "This is a test response"
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
result, err := adapter.ParseTextualResponse(core.NewNullContext(), 0, []byte(response), data.LARGE_LANGUAGE_MODEL_RESPONSE_FORMAT_JSON)
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, "This is a test response", result.Content)
|
||||
}
|
||||
|
||||
func TestLMStudioLargeLanguageModelAdapter_ParseTextualResponse_EmptyOutputContent(t *testing.T) {
|
||||
adapter := &LMStudioLargeLanguageModelAdapter{}
|
||||
|
||||
response := `{
|
||||
"model_instance_id": "test",
|
||||
"output": [
|
||||
{
|
||||
"type": "message",
|
||||
"content": ""
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
result, err := adapter.ParseTextualResponse(core.NewNullContext(), 0, []byte(response), data.LARGE_LANGUAGE_MODEL_RESPONSE_FORMAT_JSON)
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, "", result.Content)
|
||||
}
|
||||
|
||||
func TestLMStudioLargeLanguageModelAdapter_ParseTextualResponse_EmptyOutput(t *testing.T) {
|
||||
adapter := &LMStudioLargeLanguageModelAdapter{}
|
||||
|
||||
response := `{
|
||||
"model_instance_id": "test",
|
||||
"output": []
|
||||
}`
|
||||
|
||||
_, err := adapter.ParseTextualResponse(core.NewNullContext(), 0, []byte(response), data.LARGE_LANGUAGE_MODEL_RESPONSE_FORMAT_JSON)
|
||||
assert.EqualError(t, err, "failed to request third party api")
|
||||
}
|
||||
|
||||
func TestLMStudioLargeLanguageModelAdapter_ParseTextualResponse_NoContentFieldInOutput(t *testing.T) {
|
||||
adapter := &LMStudioLargeLanguageModelAdapter{}
|
||||
|
||||
response := `{
|
||||
"model_instance_id": "test",
|
||||
"output": [
|
||||
{
|
||||
"type": "message"
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
_, err := adapter.ParseTextualResponse(core.NewNullContext(), 0, []byte(response), data.LARGE_LANGUAGE_MODEL_RESPONSE_FORMAT_JSON)
|
||||
assert.EqualError(t, err, "failed to request third party api")
|
||||
}
|
||||
|
||||
func TestLMStudioLargeLanguageModelAdapter_ParseTextualResponse_InvalidJson(t *testing.T) {
|
||||
adapter := &LMStudioLargeLanguageModelAdapter{}
|
||||
|
||||
response := "error"
|
||||
|
||||
_, err := adapter.ParseTextualResponse(core.NewNullContext(), 0, []byte(response), data.LARGE_LANGUAGE_MODEL_RESPONSE_FORMAT_JSON)
|
||||
assert.EqualError(t, err, "failed to request third party api")
|
||||
}
|
||||
|
||||
func TestLMStudioLargeLanguageModelAdapter_GetOllamaRequestUrl(t *testing.T) {
|
||||
adapter := &LMStudioLargeLanguageModelAdapter{
|
||||
LMStudioServerURL: "http://localhost:1234/",
|
||||
}
|
||||
url := adapter.getLMStudioRequestUrl()
|
||||
assert.Equal(t, "http://localhost:1234/api/v1/chat", url)
|
||||
|
||||
adapter = &LMStudioLargeLanguageModelAdapter{
|
||||
LMStudioServerURL: "http://localhost:1234",
|
||||
}
|
||||
url = adapter.getLMStudioRequestUrl()
|
||||
assert.Equal(t, "http://localhost:1234/api/v1/chat", url)
|
||||
|
||||
adapter = &LMStudioLargeLanguageModelAdapter{
|
||||
LMStudioServerURL: "http://example.com/lmstudio/",
|
||||
}
|
||||
url = adapter.getLMStudioRequestUrl()
|
||||
assert.Equal(t, "http://example.com/lmstudio/api/v1/chat", url)
|
||||
}
|
||||
@@ -39,9 +39,12 @@ var AllLanguages = map[string]*LocaleInfo{
|
||||
"ru": {
|
||||
Content: ru,
|
||||
},
|
||||
"sl": {
|
||||
"sl": {
|
||||
Content: sl,
|
||||
},
|
||||
},
|
||||
"ta": {
|
||||
Content: ta,
|
||||
},
|
||||
"th": {
|
||||
Content: th,
|
||||
},
|
||||
|
||||
@@ -10,24 +10,24 @@ var ptBR = &LocaleTextItems{
|
||||
},
|
||||
DefaultTypes: &DefaultTypes{
|
||||
DecimalSeparator: core.DECIMAL_SEPARATOR_COMMA,
|
||||
DigitGroupingSymbol: core.DIGIT_GROUPING_SYMBOL_SPACE,
|
||||
DigitGroupingSymbol: core.DIGIT_GROUPING_SYMBOL_DOT,
|
||||
},
|
||||
DataConverterTextItems: &DataConverterTextItems{
|
||||
Alipay: "Alipay",
|
||||
WeChatWallet: "Wallet",
|
||||
},
|
||||
VerifyEmailTextItems: &VerifyEmailTextItems{
|
||||
Title: "Verificar Email",
|
||||
Title: "Verifique seu e-mail",
|
||||
SalutationFormat: "Olá %s,",
|
||||
DescriptionAboveBtn: "Por favor, clique no link abaixo para confirmar o seu endereço de e-mail.",
|
||||
VerifyEmail: "Verificar Email",
|
||||
DescriptionBelowBtnFormat: "Se você não se registrou para uma conta %s, basta ignorar este e-mail. Se não conseguir clicar no link acima, copie a URL acima e cole no seu navegador. O link para verificação de e-mail expirará após %v minutos.",
|
||||
DescriptionAboveBtn: "Clique no link abaixo para confirmar seu endereço de e-mail.",
|
||||
VerifyEmail: "Verificar e-mail",
|
||||
DescriptionBelowBtnFormat: "Se você não criou uma conta no %s, ignore este e-mail. Se não conseguir clicar no link acima, copie a URL e cole no navegador. O link de verificação de e-mail expira em %v minutos.",
|
||||
},
|
||||
ForgetPasswordMailTextItems: &ForgetPasswordMailTextItems{
|
||||
Title: "Redefinir Sua Senha",
|
||||
Title: "Redefina sua senha",
|
||||
SalutationFormat: "Olá %s,",
|
||||
DescriptionAboveBtn: "Recebemos recentemente uma solicitação para redefinir a sua senha. Você pode clicar no link abaixo para redefinir sua senha.",
|
||||
ResetPassword: "Redefinir Senha",
|
||||
DescriptionBelowBtnFormat: "Se você não solicitou a redefinição de senha, basta ignorar este e-mail. Se não conseguir clicar no link acima, copie a URL acima e cole no seu navegador. O link de redefinição de senha expirará após %v minutos.",
|
||||
DescriptionAboveBtn: "Recebemos recentemente uma solicitação para redefinir sua senha. Clique no link abaixo para redefini-la.",
|
||||
ResetPassword: "Redefinir senha",
|
||||
DescriptionBelowBtnFormat: "Se você não solicitou a redefinição da senha, ignore este e-mail. Se não conseguir clicar no link acima, copie a URL e cole no navegador. O link de redefinição de senha expira em %v minutos.",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package locales
|
||||
|
||||
import (
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
)
|
||||
|
||||
var ta = &LocaleTextItems{
|
||||
GlobalTextItems: &GlobalTextItems{
|
||||
AppName: "ezBookkeeping",
|
||||
},
|
||||
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,39 @@
|
||||
package middlewares
|
||||
|
||||
import (
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
||||
)
|
||||
|
||||
// APITokenIpLimit limits API token access based on IP address
|
||||
func APITokenIpLimit(config *settings.Config) core.MiddlewareHandlerFunc {
|
||||
return func(c *core.WebContext) {
|
||||
claims := c.GetTokenClaims()
|
||||
|
||||
if claims == nil {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
if claims.Type != core.USER_TOKEN_TYPE_API {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
if len(config.APITokenAllowedRemoteIPs) < 1 {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
for i := 0; i < len(config.APITokenAllowedRemoteIPs); i++ {
|
||||
if config.APITokenAllowedRemoteIPs[i].Match(c.ClientIP()) {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
utils.PrintJsonErrorResult(c, errs.ErrIPForbidden)
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,10 @@ const (
|
||||
|
||||
var ALL_ALLOWED_CLOUD_SYNC_APP_SETTING_KEY_TYPES = map[string]UserApplicationCloudSettingType{
|
||||
// Basic Settings
|
||||
"showAccountBalance": USER_APPLICATION_CLOUD_SETTING_TYPE_BOOLEAN,
|
||||
"showAccountBalance": USER_APPLICATION_CLOUD_SETTING_TYPE_BOOLEAN,
|
||||
"autoUpdateExchangeRatesData": USER_APPLICATION_CLOUD_SETTING_TYPE_BOOLEAN,
|
||||
// Navigation Bar
|
||||
"showAddTransactionButtonInDesktopNavbar": USER_APPLICATION_CLOUD_SETTING_TYPE_BOOLEAN,
|
||||
// Overview Page
|
||||
"showAmountInHomePage": USER_APPLICATION_CLOUD_SETTING_TYPE_BOOLEAN,
|
||||
"timezoneUsedForStatisticsInHomePage": USER_APPLICATION_CLOUD_SETTING_TYPE_NUMBER,
|
||||
@@ -26,6 +29,8 @@ var ALL_ALLOWED_CLOUD_SYNC_APP_SETTING_KEY_TYPES = map[string]UserApplicationClo
|
||||
"showTotalAmountInTransactionListPage": USER_APPLICATION_CLOUD_SETTING_TYPE_BOOLEAN,
|
||||
"showTagInTransactionListPage": USER_APPLICATION_CLOUD_SETTING_TYPE_BOOLEAN,
|
||||
// Transaction Edit Page
|
||||
"quickSaveButtonStyleInMobileTransactionListPage": USER_APPLICATION_CLOUD_SETTING_TYPE_NUMBER,
|
||||
"quickAddButtonActionInMobileTransactionEditPage": USER_APPLICATION_CLOUD_SETTING_TYPE_NUMBER,
|
||||
"autoSaveTransactionDraft": USER_APPLICATION_CLOUD_SETTING_TYPE_STRING,
|
||||
"autoGetCurrentGeoLocation": USER_APPLICATION_CLOUD_SETTING_TYPE_BOOLEAN,
|
||||
"alwaysShowTransactionPicturesInMobileTransactionEditPage": USER_APPLICATION_CLOUD_SETTING_TYPE_BOOLEAN,
|
||||
@@ -41,6 +46,9 @@ var ALL_ALLOWED_CLOUD_SYNC_APP_SETTING_KEY_TYPES = map[string]UserApplicationClo
|
||||
"hideCategoriesWithoutAccounts": USER_APPLICATION_CLOUD_SETTING_TYPE_BOOLEAN,
|
||||
// Exchange Rates Data Page
|
||||
"currencySortByInExchangeRatesPage": USER_APPLICATION_CLOUD_SETTING_TYPE_NUMBER,
|
||||
// Browser Cache Management
|
||||
"mapCacheExpiration": USER_APPLICATION_CLOUD_SETTING_TYPE_NUMBER,
|
||||
"exchangeRatesDataCacheExpiration": USER_APPLICATION_CLOUD_SETTING_TYPE_NUMBER,
|
||||
// Statistics Settings
|
||||
"statistics.defaultChartDataType": USER_APPLICATION_CLOUD_SETTING_TYPE_NUMBER,
|
||||
"statistics.defaultTimezoneType": USER_APPLICATION_CLOUD_SETTING_TYPE_NUMBER,
|
||||
|
||||
+2
-11
@@ -20,15 +20,6 @@ import (
|
||||
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
||||
)
|
||||
|
||||
// TokenUserAgentCreatedViaCli is the user agent of token created via cli
|
||||
const TokenUserAgentCreatedViaCli = core.ApplicationName + " Cli"
|
||||
|
||||
// TokenUserAgentForAPI is the user agent for API token
|
||||
const TokenUserAgentForAPI = core.ApplicationName + " API"
|
||||
|
||||
// TokenUserAgentForMCP is the user agent for MCP token
|
||||
const TokenUserAgentForMCP = core.ApplicationName + " MCP"
|
||||
|
||||
const tokenMaxExpiredAtUnixTime = int64(253402300799) // 9999-12-31 23:59:59 UTC
|
||||
|
||||
// TokenService represents user token service
|
||||
@@ -140,7 +131,7 @@ func (s *TokenService) CreateAPITokenViaCli(c *core.CliContext, user *models.Use
|
||||
tokenExpiredTimeDuration = time.Unix(tokenMaxExpiredAtUnixTime, 0).Sub(time.Now())
|
||||
}
|
||||
|
||||
token, _, tokenRecord, err := s.createToken(c, user, core.USER_TOKEN_TYPE_API, TokenUserAgentCreatedViaCli, "", tokenExpiredTimeDuration)
|
||||
token, _, tokenRecord, err := s.createToken(c, user, core.USER_TOKEN_TYPE_API, core.TokenUserAgentCreatedViaCli, "", tokenExpiredTimeDuration)
|
||||
return token, tokenRecord, err
|
||||
}
|
||||
|
||||
@@ -168,7 +159,7 @@ func (s *TokenService) CreateMCPTokenViaCli(c *core.CliContext, user *models.Use
|
||||
tokenExpiredTimeDuration = time.Unix(tokenMaxExpiredAtUnixTime, 0).Sub(time.Now())
|
||||
}
|
||||
|
||||
token, _, tokenRecord, err := s.createToken(c, user, core.USER_TOKEN_TYPE_MCP, TokenUserAgentCreatedViaCli, "", tokenExpiredTimeDuration)
|
||||
token, _, tokenRecord, err := s.createToken(c, user, core.USER_TOKEN_TYPE_MCP, core.TokenUserAgentCreatedViaCli, "", tokenExpiredTimeDuration)
|
||||
return token, tokenRecord, err
|
||||
}
|
||||
|
||||
|
||||
@@ -132,6 +132,23 @@ func (s *TransactionPictureService) GetPictureInfosByTransactionIds(c core.Conte
|
||||
return pictureInfoMap, err
|
||||
}
|
||||
|
||||
// GetAllPictureInfosOfAllTransactions returns all transaction picture info models
|
||||
func (s *TransactionPictureService) GetAllPictureInfosOfAllTransactions(c core.Context, uid int64) (map[int64][]*models.TransactionPictureInfo, error) {
|
||||
if uid <= 0 {
|
||||
return nil, errs.ErrUserIdInvalid
|
||||
}
|
||||
|
||||
var pictureInfos []*models.TransactionPictureInfo
|
||||
err := s.UserDataDB(uid).NewSession(c).Where("uid=? AND deleted=?", uid, false).OrderBy("picture_id asc").Find(&pictureInfos)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
pictureInfoMap := s.GetPictureInfoListMapByList(pictureInfos)
|
||||
return pictureInfoMap, err
|
||||
}
|
||||
|
||||
// GetPictureByPictureId returns the transaction picture data according to transaction picture id
|
||||
func (s *TransactionPictureService) GetPictureByPictureId(c core.Context, uid int64, pictureId int64, fileExtension string) ([]byte, error) {
|
||||
if uid <= 0 {
|
||||
|
||||
@@ -379,6 +379,32 @@ func (s *UserService) UpdateUser(c core.Context, user *models.User, modifyUserLa
|
||||
return keyProfileUpdated, emailSetToUnverified, nil
|
||||
}
|
||||
|
||||
// UpdateUserPassword updates the password of specified user
|
||||
func (s *UserService) UpdateUserPassword(c core.Context, user *models.User) error {
|
||||
if user.Uid <= 0 {
|
||||
return errs.ErrUserIdInvalid
|
||||
}
|
||||
|
||||
if user.Password == "" {
|
||||
return errs.ErrPasswordIsEmpty
|
||||
}
|
||||
|
||||
user.Password = utils.EncodePassword(user.Password, user.Salt)
|
||||
user.UpdatedUnixTime = time.Now().Unix()
|
||||
|
||||
return s.UserDB().DoTransaction(c, func(sess *xorm.Session) error {
|
||||
updatedRows, err := sess.ID(user.Uid).Cols("password", "updated_unix_time").Where("deleted=?", false).Update(user)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
} else if updatedRows < 1 {
|
||||
return errs.ErrUserNotFound
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateUserAvatar updates the custom avatar type of specified user
|
||||
func (s *UserService) UpdateUserAvatar(c core.Context, uid int64, avatarFile multipart.File, fileExtension string, oldFileExtension string) error {
|
||||
if uid <= 0 {
|
||||
|
||||
+121
-58
@@ -20,6 +20,7 @@ const (
|
||||
ebkConfigItemValueEnvNamePrefix = "EBK"
|
||||
ebkConfigItemFilePathEnvNamePrefix = "EBKCFP"
|
||||
defaultConfigPath = "/conf/ezbookkeeping.ini"
|
||||
defaultRootUrl = "%(protocol)s://%(domain)s:%(http_port)s/"
|
||||
defaultStaticRootPath = "public"
|
||||
)
|
||||
|
||||
@@ -68,11 +69,14 @@ const (
|
||||
)
|
||||
|
||||
const (
|
||||
OpenAILLMProvider string = "openai"
|
||||
OpenAICompatibleLLMProvider string = "openai_compatible"
|
||||
OpenRouterLLMProvider string = "openrouter"
|
||||
OllamaLLMProvider string = "ollama"
|
||||
GoogleAILLMProvider string = "google_ai"
|
||||
OpenAILLMProvider string = "openai"
|
||||
OpenAICompatibleLLMProvider string = "openai_compatible"
|
||||
AnthropicLLMProvider string = "anthropic"
|
||||
AnthropicCompatibleLLMProvider string = "anthropic_compatible"
|
||||
OpenRouterLLMProvider string = "openrouter"
|
||||
OllamaLLMProvider string = "ollama"
|
||||
LMStudioLLMProvider string = "lm_studio"
|
||||
GoogleAILLMProvider string = "google_ai"
|
||||
)
|
||||
|
||||
// Uuid generator types
|
||||
@@ -124,24 +128,22 @@ const (
|
||||
|
||||
// Exchange rates data source types
|
||||
const (
|
||||
ReserveBankOfAustraliaDataSource string = "reserve_bank_of_australia"
|
||||
BankOfCanadaDataSource string = "bank_of_canada"
|
||||
CzechNationalBankDataSource string = "czech_national_bank"
|
||||
DanmarksNationalbankDataSource string = "danmarks_national_bank"
|
||||
EuroCentralBankDataSource string = "euro_central_bank"
|
||||
NationalBankOfGeorgiaDataSource string = "national_bank_of_georgia"
|
||||
CentralBankOfHungaryDataSource string = "central_bank_of_hungary"
|
||||
BankOfIsraelDataSource string = "bank_of_israel"
|
||||
CentralBankOfMyanmarDataSource string = "central_bank_of_myanmar"
|
||||
NorgesBankDataSource string = "norges_bank"
|
||||
NationalBankOfPolandDataSource string = "national_bank_of_poland"
|
||||
NationalBankOfRomaniaDataSource string = "national_bank_of_romania"
|
||||
BankOfRussiaDataSource string = "bank_of_russia"
|
||||
SwissNationalBankDataSource string = "swiss_national_bank"
|
||||
NationalBankOfUkraineDataSource string = "national_bank_of_ukraine"
|
||||
CentralBankOfUzbekistanDataSource string = "central_bank_of_uzbekistan"
|
||||
InternationalMonetaryFundDataSource string = "international_monetary_fund"
|
||||
UserCustomExchangeRatesDataSource string = "user_custom"
|
||||
BankOfCanadaDataSource string = "bank_of_canada"
|
||||
CzechNationalBankDataSource string = "czech_national_bank"
|
||||
DanmarksNationalbankDataSource string = "danmarks_national_bank"
|
||||
EuroCentralBankDataSource string = "euro_central_bank"
|
||||
NationalBankOfGeorgiaDataSource string = "national_bank_of_georgia"
|
||||
CentralBankOfHungaryDataSource string = "central_bank_of_hungary"
|
||||
BankOfIsraelDataSource string = "bank_of_israel"
|
||||
CentralBankOfMyanmarDataSource string = "central_bank_of_myanmar"
|
||||
NorgesBankDataSource string = "norges_bank"
|
||||
NationalBankOfPolandDataSource string = "national_bank_of_poland"
|
||||
NationalBankOfRomaniaDataSource string = "national_bank_of_romania"
|
||||
BankOfRussiaDataSource string = "bank_of_russia"
|
||||
SwissNationalBankDataSource string = "swiss_national_bank"
|
||||
NationalBankOfUkraineDataSource string = "national_bank_of_ukraine"
|
||||
CentralBankOfUzbekistanDataSource string = "central_bank_of_uzbekistan"
|
||||
UserCustomExchangeRatesDataSource string = "user_custom"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -161,8 +163,9 @@ const (
|
||||
|
||||
defaultWebDAVRequestTimeout uint32 = 10000 // 10 seconds
|
||||
|
||||
defaultAIRecognitionPictureMaxSize uint32 = 10485760 // 10MB
|
||||
defaultLargeLanguageModelAPIRequestTimeout uint32 = 60000 // 60 seconds
|
||||
defaultAIRecognitionPictureMaxSize uint32 = 10485760 // 10MB
|
||||
defaultAnthropicLargeLanguageModelAPIMaximumTokens uint32 = 1024
|
||||
defaultLargeLanguageModelAPIRequestTimeout uint32 = 60000 // 60 seconds
|
||||
|
||||
defaultInMemoryDuplicateCheckerCleanupInterval uint32 = 60 // 1 minutes
|
||||
defaultDuplicateSubmissionsInterval uint32 = 300 // 5 minutes
|
||||
@@ -244,10 +247,21 @@ type LLMConfig struct {
|
||||
OpenAICompatibleBaseURL string
|
||||
OpenAICompatibleAPIKey string
|
||||
OpenAICompatibleModelID string
|
||||
AnthropicAPIKey string
|
||||
AnthropicModelID string
|
||||
AnthropicMaxTokens uint32
|
||||
AnthropicCompatibleBaseURL string
|
||||
AnthropicCompatibleAPIVersion string
|
||||
AnthropicCompatibleAPIKey string
|
||||
AnthropicCompatibleModelID string
|
||||
AnthropicCompatibleMaxTokens uint32
|
||||
OpenRouterAPIKey string
|
||||
OpenRouterModelID string
|
||||
OllamaServerURL string
|
||||
OllamaModelID string
|
||||
LMStudioServerURL string
|
||||
LMStudioToken string
|
||||
LMStudioModelID string
|
||||
GoogleAIAPIKey string
|
||||
GoogleAIModelID string
|
||||
LargeLanguageModelAPIRequestTimeout uint32
|
||||
@@ -356,6 +370,7 @@ type Config struct {
|
||||
PasswordResetTokenExpiredTime uint32
|
||||
PasswordResetTokenExpiredTimeDuration time.Duration
|
||||
EnableAPIToken bool
|
||||
APITokenAllowedRemoteIPs []*core.IPPattern
|
||||
MaxFailuresPerIpPerMinute uint32
|
||||
MaxFailuresPerUserPerMinute uint32
|
||||
|
||||
@@ -627,7 +642,10 @@ func loadServerConfiguration(config *Config, configFile *ini.File, sectionName s
|
||||
}
|
||||
|
||||
config.Domain = getConfigItemStringValue(configFile, sectionName, "domain", defaultDomain)
|
||||
config.RootUrl = getConfigItemStringValue(configFile, sectionName, "root_url", fmt.Sprintf("%s://%s:%d/", string(config.Protocol), config.Domain, config.HttpPort))
|
||||
config.RootUrl = getConfigItemStringValue(configFile, sectionName, "root_url", defaultRootUrl)
|
||||
config.RootUrl = strings.ReplaceAll(config.RootUrl, "%(protocol)s", string(config.Protocol))
|
||||
config.RootUrl = strings.ReplaceAll(config.RootUrl, "%(domain)s", config.Domain)
|
||||
config.RootUrl = strings.ReplaceAll(config.RootUrl, "%(http_port)s", strconv.Itoa(int(config.HttpPort)))
|
||||
|
||||
if config.RootUrl[len(config.RootUrl)-1] != '/' {
|
||||
config.RootUrl += "/"
|
||||
@@ -650,29 +668,13 @@ func loadServerConfiguration(config *Config, configFile *ini.File, sectionName s
|
||||
}
|
||||
|
||||
func loadMCPServerConfiguration(config *Config, configFile *ini.File, sectionName string) error {
|
||||
var err error
|
||||
|
||||
config.EnableMCPServer = getConfigItemBoolValue(configFile, sectionName, "enable_mcp", false)
|
||||
mcpAllowedRemoteIps := getConfigItemStringValue(configFile, sectionName, "mcp_allowed_remote_ips", "")
|
||||
config.MCPAllowedRemoteIPs, err = getIPPatterns(configFile, sectionName, "mcp_allowed_remote_ips", "")
|
||||
|
||||
if mcpAllowedRemoteIps != "" {
|
||||
remoteIPs := strings.Split(mcpAllowedRemoteIps, ",")
|
||||
config.MCPAllowedRemoteIPs = make([]*core.IPPattern, 0, len(remoteIPs))
|
||||
|
||||
for i := 0; i < len(remoteIPs); i++ {
|
||||
ip := strings.TrimSpace(remoteIPs[i])
|
||||
pattern, err := core.ParseIPPattern(ip)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if pattern == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
config.MCPAllowedRemoteIPs = append(config.MCPAllowedRemoteIPs, pattern)
|
||||
}
|
||||
} else {
|
||||
config.MCPAllowedRemoteIPs = nil
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -857,10 +859,16 @@ func loadLLMConfiguration(configFile *ini.File, sectionName string) (*LLMConfig,
|
||||
llmConfig.LLMProvider = OpenAILLMProvider
|
||||
} else if llmProvider == OpenAICompatibleLLMProvider {
|
||||
llmConfig.LLMProvider = OpenAICompatibleLLMProvider
|
||||
} else if llmProvider == AnthropicLLMProvider {
|
||||
llmConfig.LLMProvider = AnthropicLLMProvider
|
||||
} else if llmProvider == AnthropicCompatibleLLMProvider {
|
||||
llmConfig.LLMProvider = AnthropicCompatibleLLMProvider
|
||||
} else if llmProvider == OpenRouterLLMProvider {
|
||||
llmConfig.LLMProvider = OpenRouterLLMProvider
|
||||
} else if llmProvider == OllamaLLMProvider {
|
||||
llmConfig.LLMProvider = OllamaLLMProvider
|
||||
} else if llmProvider == LMStudioLLMProvider {
|
||||
llmConfig.LLMProvider = LMStudioLLMProvider
|
||||
} else if llmProvider == GoogleAILLMProvider {
|
||||
llmConfig.LLMProvider = GoogleAILLMProvider
|
||||
} else {
|
||||
@@ -874,12 +882,26 @@ func loadLLMConfiguration(configFile *ini.File, sectionName string) (*LLMConfig,
|
||||
llmConfig.OpenAICompatibleAPIKey = getConfigItemStringValue(configFile, sectionName, "openai_compatible_api_key")
|
||||
llmConfig.OpenAICompatibleModelID = getConfigItemStringValue(configFile, sectionName, "openai_compatible_model_id")
|
||||
|
||||
llmConfig.AnthropicAPIKey = getConfigItemStringValue(configFile, sectionName, "anthropic_api_key")
|
||||
llmConfig.AnthropicModelID = getConfigItemStringValue(configFile, sectionName, "anthropic_model_id")
|
||||
llmConfig.AnthropicMaxTokens = getConfigItemUint32Value(configFile, sectionName, "anthropic_max_tokens", defaultAnthropicLargeLanguageModelAPIMaximumTokens)
|
||||
|
||||
llmConfig.AnthropicCompatibleBaseURL = getConfigItemStringValue(configFile, sectionName, "anthropic_compatible_base_url")
|
||||
llmConfig.AnthropicCompatibleAPIVersion = getConfigItemStringValue(configFile, sectionName, "anthropic_compatible_api_version")
|
||||
llmConfig.AnthropicCompatibleAPIKey = getConfigItemStringValue(configFile, sectionName, "anthropic_compatible_api_key")
|
||||
llmConfig.AnthropicCompatibleModelID = getConfigItemStringValue(configFile, sectionName, "anthropic_compatible_model_id")
|
||||
llmConfig.AnthropicCompatibleMaxTokens = getConfigItemUint32Value(configFile, sectionName, "anthropic_compatible_max_tokens", defaultAnthropicLargeLanguageModelAPIMaximumTokens)
|
||||
|
||||
llmConfig.OpenRouterAPIKey = getConfigItemStringValue(configFile, sectionName, "openrouter_api_key")
|
||||
llmConfig.OpenRouterModelID = getConfigItemStringValue(configFile, sectionName, "openrouter_model_id")
|
||||
|
||||
llmConfig.OllamaServerURL = getConfigItemStringValue(configFile, sectionName, "ollama_server_url")
|
||||
llmConfig.OllamaModelID = getConfigItemStringValue(configFile, sectionName, "ollama_model_id")
|
||||
|
||||
llmConfig.LMStudioServerURL = getConfigItemStringValue(configFile, sectionName, "lm_studio_server_url")
|
||||
llmConfig.LMStudioToken = getConfigItemStringValue(configFile, sectionName, "lm_studio_token")
|
||||
llmConfig.LMStudioModelID = getConfigItemStringValue(configFile, sectionName, "lm_studio_model_id")
|
||||
|
||||
llmConfig.GoogleAIAPIKey = getConfigItemStringValue(configFile, sectionName, "google_ai_api_key")
|
||||
llmConfig.GoogleAIModelID = getConfigItemStringValue(configFile, sectionName, "google_ai_model_id")
|
||||
|
||||
@@ -939,6 +961,8 @@ func loadCronConfiguration(config *Config, configFile *ini.File, sectionName str
|
||||
}
|
||||
|
||||
func loadSecurityConfiguration(config *Config, configFile *ini.File, sectionName string) error {
|
||||
var err error
|
||||
|
||||
config.SecretKeyNoSet = !getConfigItemIsSet(configFile, sectionName, "secret_key")
|
||||
config.SecretKey = getConfigItemStringValue(configFile, sectionName, "secret_key", defaultSecretKey)
|
||||
|
||||
@@ -981,6 +1005,11 @@ func loadSecurityConfiguration(config *Config, configFile *ini.File, sectionName
|
||||
config.PasswordResetTokenExpiredTimeDuration = time.Duration(config.PasswordResetTokenExpiredTime) * time.Second
|
||||
|
||||
config.EnableAPIToken = getConfigItemBoolValue(configFile, sectionName, "enable_api_token", false)
|
||||
config.APITokenAllowedRemoteIPs, err = getIPPatterns(configFile, sectionName, "api_token_allowed_remote_ips", "")
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
config.MaxFailuresPerIpPerMinute = getConfigItemUint32Value(configFile, sectionName, "max_failures_per_ip_per_minute", defaultMaxFailuresPerIpPerMinute)
|
||||
config.MaxFailuresPerUserPerMinute = getConfigItemUint32Value(configFile, sectionName, "max_failures_per_user_per_minute", defaultMaxFailuresPerUserPerMinute)
|
||||
@@ -1160,8 +1189,7 @@ func loadMapConfiguration(config *Config, configFile *ini.File, sectionName stri
|
||||
func loadExchangeRatesConfiguration(config *Config, configFile *ini.File, sectionName string) error {
|
||||
dataSource := getConfigItemStringValue(configFile, sectionName, "data_source")
|
||||
|
||||
if dataSource == ReserveBankOfAustraliaDataSource ||
|
||||
dataSource == BankOfCanadaDataSource ||
|
||||
if dataSource == BankOfCanadaDataSource ||
|
||||
dataSource == CzechNationalBankDataSource ||
|
||||
dataSource == DanmarksNationalbankDataSource ||
|
||||
dataSource == EuroCentralBankDataSource ||
|
||||
@@ -1176,7 +1204,6 @@ func loadExchangeRatesConfiguration(config *Config, configFile *ini.File, sectio
|
||||
dataSource == SwissNationalBankDataSource ||
|
||||
dataSource == NationalBankOfUkraineDataSource ||
|
||||
dataSource == CentralBankOfUzbekistanDataSource ||
|
||||
dataSource == InternationalMonetaryFundDataSource ||
|
||||
dataSource == UserCustomExchangeRatesDataSource {
|
||||
config.ExchangeRatesDataSource = dataSource
|
||||
} else {
|
||||
@@ -1225,6 +1252,34 @@ func getFinalPath(workingPath, p string) (string, error) {
|
||||
return p, err
|
||||
}
|
||||
|
||||
func getIPPatterns(configFile *ini.File, sectionName string, itemName string, defaultValue string) ([]*core.IPPattern, error) {
|
||||
configValue := getConfigItemStringValue(configFile, sectionName, itemName, defaultValue)
|
||||
|
||||
if configValue == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
remoteIPs := strings.Split(configValue, ",")
|
||||
ipPatterns := make([]*core.IPPattern, 0, len(remoteIPs))
|
||||
|
||||
for i := 0; i < len(remoteIPs); i++ {
|
||||
ip := strings.TrimSpace(remoteIPs[i])
|
||||
pattern, err := core.ParseIPPattern(ip)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if pattern == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
ipPatterns = append(ipPatterns, pattern)
|
||||
}
|
||||
|
||||
return ipPatterns, nil
|
||||
}
|
||||
|
||||
func getMultiLanguageContentConfig(configFile *ini.File, sectionName string, enableKey string, contentKey string) MultiLanguageContentConfig {
|
||||
config := MultiLanguageContentConfig{
|
||||
Enabled: getConfigItemBoolValue(configFile, sectionName, enableKey, false),
|
||||
@@ -1259,7 +1314,7 @@ func getConfigItemIsSet(configFile *ini.File, sectionName string, itemName strin
|
||||
return false
|
||||
}
|
||||
|
||||
return section.Key(itemName).String() != ""
|
||||
return section.Key(itemName).Value() != ""
|
||||
}
|
||||
|
||||
func getConfigItemStringValue(configFile *ini.File, sectionName string, itemName string, defaultValue ...string) string {
|
||||
@@ -1270,11 +1325,13 @@ func getConfigItemStringValue(configFile *ini.File, sectionName string, itemName
|
||||
}
|
||||
|
||||
section := configFile.Section(sectionName)
|
||||
key := section.Key(itemName)
|
||||
value := key.Value()
|
||||
|
||||
if len(defaultValue) > 0 {
|
||||
return section.Key(itemName).MustString(defaultValue[0])
|
||||
if len(value) == 0 && len(defaultValue) > 0 {
|
||||
return defaultValue[0]
|
||||
} else {
|
||||
return section.Key(itemName).String()
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1290,7 +1347,7 @@ func getConfigItemUint8Value(configFile *ini.File, sectionName string, itemName
|
||||
}
|
||||
|
||||
section := configFile.Section(sectionName)
|
||||
value, err := strconv.ParseUint(section.Key(itemName).String(), 10, 8)
|
||||
value, err := strconv.ParseUint(section.Key(itemName).Value(), 10, 8)
|
||||
|
||||
if err == nil {
|
||||
return uint8(value)
|
||||
@@ -1311,7 +1368,7 @@ func getConfigItemUint16Value(configFile *ini.File, sectionName string, itemName
|
||||
}
|
||||
|
||||
section := configFile.Section(sectionName)
|
||||
value, err := strconv.ParseUint(section.Key(itemName).String(), 10, 16)
|
||||
value, err := strconv.ParseUint(section.Key(itemName).Value(), 10, 16)
|
||||
|
||||
if err == nil {
|
||||
return uint16(value)
|
||||
@@ -1332,7 +1389,7 @@ func getConfigItemUint32Value(configFile *ini.File, sectionName string, itemName
|
||||
}
|
||||
|
||||
section := configFile.Section(sectionName)
|
||||
value, err := strconv.ParseUint(section.Key(itemName).String(), 10, 32)
|
||||
value, err := strconv.ParseUint(section.Key(itemName).Value(), 10, 32)
|
||||
|
||||
if err == nil {
|
||||
return uint32(value)
|
||||
@@ -1353,7 +1410,13 @@ func getConfigItemBoolValue(configFile *ini.File, sectionName string, itemName s
|
||||
}
|
||||
|
||||
section := configFile.Section(sectionName)
|
||||
return section.Key(itemName).MustBool(defaultValue)
|
||||
value, err := strconv.ParseBool(section.Key(itemName).Value())
|
||||
|
||||
if err == nil {
|
||||
return value
|
||||
}
|
||||
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
func getConfigItemValueFromEnvironment(sectionName string, itemName string) string {
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
)
|
||||
|
||||
// ConfigContainer contains the current setting config
|
||||
type ConfigContainer struct {
|
||||
current *Config
|
||||
@@ -13,10 +7,7 @@ type ConfigContainer struct {
|
||||
|
||||
// Initialize a config container singleton instance
|
||||
var (
|
||||
Version string
|
||||
CommitHash string
|
||||
BuildTime string
|
||||
Container = &ConfigContainer{}
|
||||
Container = &ConfigContainer{}
|
||||
)
|
||||
|
||||
// SetCurrentConfig sets the current config by a given config
|
||||
@@ -28,11 +19,3 @@ func SetCurrentConfig(config *Config) {
|
||||
func (c *ConfigContainer) GetCurrentConfig() *Config {
|
||||
return c.current
|
||||
}
|
||||
|
||||
func GetUserAgent() string {
|
||||
if Version == "" {
|
||||
return core.ApplicationName
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s/%s", core.ApplicationName, Version)
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ func NewWebDAVObjectStorage(config *settings.Config, pathPrefix string) (*WebDAV
|
||||
webDavConfig := config.WebDAVConfig
|
||||
|
||||
storage := &WebDAVObjectStorage{
|
||||
httpClient: httpclient.NewHttpClient(webDavConfig.RequestTimeout, webDavConfig.Proxy, webDavConfig.SkipTLSVerify, settings.GetUserAgent(), false),
|
||||
httpClient: httpclient.NewHttpClient(webDavConfig.RequestTimeout, webDavConfig.Proxy, webDavConfig.SkipTLSVerify, core.GetOutgoingUserAgent(), false),
|
||||
webDavConfig: webDavConfig,
|
||||
rootPath: webDavConfig.RootPath,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
---
|
||||
name: ezbookkeeping
|
||||
description: Use ezBookkeeping API Tools script to record new transactions, query transactions, retrieve account information, retrieve categories, retrieve tags, and retrieve exchange rate data in the self hosted personal finance application ezBookkeeping.
|
||||
---
|
||||
|
||||
# ezBookkeeping API Tools
|
||||
|
||||
## Usage
|
||||
|
||||
### List all supported commands
|
||||
|
||||
Linux / macOS
|
||||
|
||||
```bash
|
||||
sh scripts/ebktools.sh list
|
||||
```
|
||||
|
||||
Windows
|
||||
|
||||
```powershell
|
||||
scripts\ebktools.ps1 list
|
||||
```
|
||||
|
||||
### Show help for a specific command
|
||||
|
||||
Linux / macOS
|
||||
|
||||
```bash
|
||||
sh scripts/ebktools.sh help <command>
|
||||
```
|
||||
|
||||
Windows
|
||||
|
||||
```powershell
|
||||
scripts\ebktools.ps1 help <command>
|
||||
```
|
||||
|
||||
### Call API
|
||||
|
||||
Linux / macOS
|
||||
|
||||
```bash
|
||||
sh scripts/ebktools.sh [global-options] <command> [command-options]
|
||||
```
|
||||
|
||||
Windows
|
||||
|
||||
```powershell
|
||||
scripts\ebktools.ps1 [global-options] <command> [command-options]
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
If the script reports that the environment variable `EBKTOOL_SERVER_BASEURL` or `EBKTOOL_TOKEN` is not set, user can define them as system environment variables, or create a `.env` file in the user home directory that contains these two variables and place it there.
|
||||
|
||||
The meanings of these environment variables are as follows:
|
||||
|
||||
| Variable | Required | Description |
|
||||
| --- | --- | --- |
|
||||
| `EBKTOOL_SERVER_BASEURL` | Required | ezBookkeeping server base URL (e.g., `http://localhost:8080`) |
|
||||
| `EBKTOOL_TOKEN` | Required | ezBookkeeping API token |
|
||||
|
||||
## Reference
|
||||
|
||||
ezBookkeeping: [https://ezbookkeeping.mayswind.net](https://ezbookkeeping.mayswind.net)
|
||||
Executable
+1529
File diff suppressed because it is too large
Load Diff
Executable
+1266
File diff suppressed because it is too large
Load Diff
+5
-5
@@ -34,6 +34,7 @@ import { ThemeType } from '@/core/theme.ts';
|
||||
import { isProduction } from '@/lib/version.ts';
|
||||
import { initMapProvider } from '@/lib/map/index.ts';
|
||||
import { isUserLogined, isUserUnlocked } from '@/lib/userstate.ts';
|
||||
import { updateMapCacheExpiration } from '@/lib/cache.ts';
|
||||
import { getSystemTheme, setExpenseAndIncomeAmountColor } from '@/lib/ui/common.ts';
|
||||
|
||||
const { tt, getCurrentLanguageInfo, setLanguage, initLocale } = useI18n();
|
||||
@@ -120,12 +121,11 @@ if (isUserLogined() && initialRoutePath !== '/verify_email' && initialRoutePath
|
||||
rootStore.setNotificationContent(response.notificationContent);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// auto refresh exchange rates data
|
||||
if (settingsStore.appSettings.autoUpdateExchangeRatesData) {
|
||||
exchangeRatesStore.getLatestExchangeRates({ silent: true, force: false });
|
||||
}
|
||||
updateMapCacheExpiration(settingsStore.appSettings.mapCacheExpiration);
|
||||
exchangeRatesStore.removeExpiredExchangeRates(true);
|
||||
exchangeRatesStore.autoUpdateExchangeRatesData();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+14
-6
@@ -22,10 +22,13 @@ import { useExchangeRatesStore } from '@/stores/exchangeRates.ts';
|
||||
|
||||
import { APPLICATION_LOGO_PATH } from '@/consts/asset.ts';
|
||||
import { ThemeType } from '@/core/theme.ts';
|
||||
|
||||
import { isFunction } from '@/lib/common.ts';
|
||||
import { isProduction } from '@/lib/version.ts';
|
||||
import { getTheme, isEnableSwipeBack, isEnableAnimate } from '@/lib/settings.ts';
|
||||
import { initMapProvider } from '@/lib/map/index.ts';
|
||||
import { isUserLogined, isUserUnlocked } from '@/lib/userstate.ts';
|
||||
import { updateMapCacheExpiration } from '@/lib/cache.ts';
|
||||
import { setExpenseAndIncomeAmountColor } from '@/lib/ui/common.ts';
|
||||
import { isiOSHomeScreenMode, isModalShowing, setAppFontSize } from '@/lib/ui/mobile.ts';
|
||||
|
||||
@@ -160,7 +163,7 @@ onMounted(() => {
|
||||
f7.on('sheetOpen', (sheet: Sheet.Sheet) => onBackdropChanged(sheet));
|
||||
f7.on('sheetClose', (sheet: Sheet.Sheet) => onBackdropChanged(sheet));
|
||||
|
||||
f7.on('pageBeforeOut', () => {
|
||||
f7.on('pageBeforeOut', () => {
|
||||
if (isModalShowing()) {
|
||||
f7.actions.close('.actions-modal.modal-in', false);
|
||||
f7.dialog.close('.dialog.modal-in', false);
|
||||
@@ -180,6 +183,12 @@ onMounted(() => {
|
||||
const languageInfo = getCurrentLanguageInfo();
|
||||
initMapProvider(languageInfo?.alternativeLanguageTag);
|
||||
});
|
||||
|
||||
document.addEventListener('dragstart', (e) => {
|
||||
if (!e.target || !('closest' in e.target) || !isFunction(e.target.closest) || !e.target.closest('.dragenabled')) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}, true);
|
||||
});
|
||||
|
||||
watch(currentNotificationContent, (newValue) => {
|
||||
@@ -227,12 +236,11 @@ if (isUserLogined()) {
|
||||
rootStore.setNotificationContent(response.notificationContent);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// auto refresh exchange rates data
|
||||
if (settingsStore.appSettings.autoUpdateExchangeRatesData) {
|
||||
exchangeRatesStore.getLatestExchangeRates({ silent: true, force: false });
|
||||
}
|
||||
updateMapCacheExpiration(settingsStore.appSettings.mapCacheExpiration);
|
||||
exchangeRatesStore.removeExpiredExchangeRates(true);
|
||||
exchangeRatesStore.autoUpdateExchangeRatesData();
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -44,6 +44,8 @@ export interface AccountBalanceTrendsChartItem {
|
||||
maximumBalance: number;
|
||||
medianBalance: number;
|
||||
averageBalance: number;
|
||||
q1Balance: number;
|
||||
q3Balance: number;
|
||||
}
|
||||
|
||||
export interface CommonAccountBalanceTrendsChartProps {
|
||||
@@ -162,6 +164,8 @@ export function useAccountBalanceTrendsChartBase(props: CommonAccountBalanceTren
|
||||
let lastMaximumBalance = lastClosingBalance;
|
||||
let lastMedianBalance = lastClosingBalance;
|
||||
let lastAverageBalance = lastClosingBalance;
|
||||
let lastQ1Balance = lastClosingBalance;
|
||||
let lastQ3Balance = lastClosingBalance;
|
||||
|
||||
for (const dateRange of allDateRanges.value) {
|
||||
const minDateTime = parseDateTimeFromUnixTime(dateRange.minUnixTime);
|
||||
@@ -205,6 +209,8 @@ export function useAccountBalanceTrendsChartBase(props: CommonAccountBalanceTren
|
||||
const maximumBalance = Math.max(...dataItems.map(item => item.accountClosingBalance));
|
||||
const medianBalance = allDataItemsSortedByClosingBalance[Math.floor(allDataItemsSortedByClosingBalance.length / 2)]!.accountClosingBalance;
|
||||
const averageBalance = Math.trunc(sumAmounts(dataItems.map(item => item.accountClosingBalance)) / dataItems.length);
|
||||
const q1Balance = allDataItemsSortedByClosingBalance[Math.floor(allDataItemsSortedByClosingBalance.length / 4)]!.accountClosingBalance;
|
||||
const q3Balance = allDataItemsSortedByClosingBalance[Math.floor(allDataItemsSortedByClosingBalance.length * 3 / 4)]!.accountClosingBalance;
|
||||
|
||||
if (props.account.isAsset) {
|
||||
lastOpeningBalance = openingBalance;
|
||||
@@ -213,6 +219,8 @@ export function useAccountBalanceTrendsChartBase(props: CommonAccountBalanceTren
|
||||
lastMaximumBalance = maximumBalance;
|
||||
lastMedianBalance = medianBalance;
|
||||
lastAverageBalance = averageBalance;
|
||||
lastQ1Balance = q1Balance;
|
||||
lastQ3Balance = q3Balance;
|
||||
} else if (props.account.isLiability) {
|
||||
lastOpeningBalance = -openingBalance;
|
||||
lastClosingBalance = -closingBalance;
|
||||
@@ -220,6 +228,8 @@ export function useAccountBalanceTrendsChartBase(props: CommonAccountBalanceTren
|
||||
lastMaximumBalance = -maximumBalance;
|
||||
lastMedianBalance = -medianBalance;
|
||||
lastAverageBalance = -averageBalance;
|
||||
lastQ1Balance = -q1Balance;
|
||||
lastQ3Balance = -q3Balance;
|
||||
} else {
|
||||
lastOpeningBalance = openingBalance;
|
||||
lastClosingBalance = closingBalance;
|
||||
@@ -227,6 +237,8 @@ export function useAccountBalanceTrendsChartBase(props: CommonAccountBalanceTren
|
||||
lastMaximumBalance = maximumBalance;
|
||||
lastMedianBalance = medianBalance;
|
||||
lastAverageBalance = averageBalance;
|
||||
lastQ1Balance = q1Balance;
|
||||
lastQ3Balance = q3Balance;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,7 +249,9 @@ export function useAccountBalanceTrendsChartBase(props: CommonAccountBalanceTren
|
||||
minimumBalance: lastMinimumBalance,
|
||||
maximumBalance: lastMaximumBalance,
|
||||
medianBalance: lastMedianBalance,
|
||||
averageBalance: lastAverageBalance
|
||||
averageBalance: lastAverageBalance,
|
||||
q1Balance: lastQ1Balance,
|
||||
q3Balance: lastQ3Balance
|
||||
});
|
||||
|
||||
lastOpeningBalance = lastClosingBalance;
|
||||
|
||||
@@ -81,7 +81,7 @@ export function usePieChartBase(props: CommonPieChartProps) {
|
||||
|
||||
accumulatedPaintPercent += finalItem.paintPercent;
|
||||
finalItem.displayPercent = formatPercentToLocalizedNumerals(finalItem.percent, 2, '<0.01');
|
||||
finalItem.displayValue = props.amountValue ? formatAmountToLocalizedNumeralsWithCurrency(value, props.defaultCurrency) : formatNumberToLocalizedNumerals(value);
|
||||
finalItem.displayValue = props.amountValue ? formatAmountToLocalizedNumeralsWithCurrency(value, props.defaultCurrency) : formatNumberToLocalizedNumerals(value, 2);
|
||||
|
||||
validItems.push(finalItem);
|
||||
}
|
||||
|
||||
@@ -78,6 +78,9 @@ const allSeries = computed<AccountBalanceTrendsChartDataItem[]>(() => {
|
||||
series.areaStyle = {};
|
||||
} else if (props.type === AccountBalanceTrendChartType.Column.type) {
|
||||
series.type = 'bar';
|
||||
} else if (props.type === AccountBalanceTrendChartType.Boxplot.type) {
|
||||
series.type = 'boxplot';
|
||||
series.itemStyle.borderColor = series.itemStyle.color;
|
||||
} else if (props.type === AccountBalanceTrendChartType.Candlestick.type) {
|
||||
const expenseIncomeAmountColor = getExpenseAndIncomeAmountColor(userStore.currentUserExpenseAmountColor, userStore.currentUserIncomeAmountColor, isDarkMode.value);
|
||||
series.type = 'candlestick';
|
||||
@@ -88,7 +91,15 @@ const allSeries = computed<AccountBalanceTrendsChartDataItem[]>(() => {
|
||||
}
|
||||
|
||||
for (const item of allDataItems.value) {
|
||||
if (props.type === AccountBalanceTrendChartType.Candlestick.type) {
|
||||
if (props.type === AccountBalanceTrendChartType.Boxplot.type) {
|
||||
series.data.push([
|
||||
item.minimumBalance,
|
||||
item.q1Balance,
|
||||
item.medianBalance,
|
||||
item.q3Balance,
|
||||
item.maximumBalance
|
||||
]);
|
||||
} else if (props.type === AccountBalanceTrendChartType.Candlestick.type) {
|
||||
series.data.push([
|
||||
item.openingBalance,
|
||||
item.closingBalance,
|
||||
@@ -114,20 +125,26 @@ const yAxisWidth = computed<number>(() => {
|
||||
|
||||
for (const series of allSeries.value) {
|
||||
for (const data of series.data) {
|
||||
let value: number;
|
||||
let currentMinValue: number;
|
||||
let currentMaxValue: number;
|
||||
|
||||
if (isArray(data)) {
|
||||
value = data[1] as number; // for candlestick, use closing balance
|
||||
if (isArray(data) && props.type === AccountBalanceTrendChartType.Boxplot.type) {
|
||||
currentMinValue = data[0] as number;
|
||||
currentMaxValue = data[4] as number;
|
||||
} else if (isArray(data) && props.type === AccountBalanceTrendChartType.Candlestick.type) {
|
||||
currentMinValue = data[2] as number;
|
||||
currentMaxValue = data[3] as number;
|
||||
} else {
|
||||
value = data as number; // for line or bar chart
|
||||
currentMinValue = data as number;
|
||||
currentMaxValue = data as number;
|
||||
}
|
||||
|
||||
if (value > maxValue) {
|
||||
maxValue = value;
|
||||
if (currentMaxValue > maxValue) {
|
||||
maxValue = currentMaxValue;
|
||||
}
|
||||
|
||||
if (value < minValue) {
|
||||
minValue = value;
|
||||
if (currentMinValue < minValue) {
|
||||
minValue = currentMinValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -172,7 +189,54 @@ const chartOptions = computed<object>(() => {
|
||||
color: isDarkMode.value ? '#eee' : '#333'
|
||||
},
|
||||
formatter: (params: CallbackDataParams[]) => {
|
||||
if (props.type === AccountBalanceTrendChartType.Candlestick.type) {
|
||||
if (props.type === AccountBalanceTrendChartType.Boxplot.type) {
|
||||
const dataIndex = params[0]!.dataIndex;
|
||||
const dataItem = allDataItems.value[dataIndex] as AccountBalanceTrendsChartItem;
|
||||
const displayItems: NameValue[] = [
|
||||
{
|
||||
name: tt('Minimum Balance'),
|
||||
value: formatAmountToLocalizedNumeralsWithCurrency(dataItem.minimumBalance, props.account.currency)
|
||||
},
|
||||
{
|
||||
name: tt('Q1 Balance (First Quartile)'),
|
||||
value: formatAmountToLocalizedNumeralsWithCurrency(dataItem.q1Balance, props.account.currency)
|
||||
},
|
||||
{
|
||||
name: tt('Median Balance'),
|
||||
value: formatAmountToLocalizedNumeralsWithCurrency(dataItem.medianBalance, props.account.currency)
|
||||
},
|
||||
{
|
||||
name: tt('Q3 Balance (Third Quartile)'),
|
||||
value: formatAmountToLocalizedNumeralsWithCurrency(dataItem.q3Balance, props.account.currency)
|
||||
},
|
||||
{
|
||||
name: tt('Maximum Balance'),
|
||||
value: formatAmountToLocalizedNumeralsWithCurrency(dataItem.maximumBalance, props.account.currency)
|
||||
},
|
||||
{
|
||||
name: tt('Opening Balance'),
|
||||
value: formatAmountToLocalizedNumeralsWithCurrency(dataItem.openingBalance, props.account.currency)
|
||||
},
|
||||
{
|
||||
name: tt('Closing Balance'),
|
||||
value: formatAmountToLocalizedNumeralsWithCurrency(dataItem.closingBalance, props.account.currency)
|
||||
}
|
||||
];
|
||||
|
||||
let tooltip = `${params[0]!.name} ${props.legendName}<br/>`;
|
||||
|
||||
for (const [displayItem, index] of itemAndIndex(displayItems)) {
|
||||
if (index === 5) {
|
||||
tooltip += '<div style="border-bottom: ' + (isDarkMode.value ? '#eee' : '#333') + ' dashed 1px"></div>';
|
||||
}
|
||||
|
||||
tooltip += `<div><span class="chart-pointer" style="background-color: #${DEFAULT_CHART_COLORS[index]}"></span>`
|
||||
+ `<span>${displayItem.name}</span><span class="ms-5" style="float: inline-end">${displayItem.value}</span>`
|
||||
+ `</div>`;
|
||||
}
|
||||
|
||||
return tooltip;
|
||||
} else if (props.type === AccountBalanceTrendChartType.Candlestick.type) {
|
||||
const dataIndex = params[0]!.dataIndex;
|
||||
const dataItem = allDataItems.value[dataIndex] as AccountBalanceTrendsChartItem;
|
||||
const displayItems: NameValue[] = [
|
||||
@@ -205,8 +269,12 @@ const chartOptions = computed<object>(() => {
|
||||
let tooltip = `${params[0]!.name} ${props.legendName}<br/>`;
|
||||
|
||||
for (const [displayItem, index] of itemAndIndex(displayItems)) {
|
||||
if (index === 4) {
|
||||
tooltip += '<div style="border-bottom: ' + (isDarkMode.value ? '#eee' : '#333') + ' dashed 1px"></div>';
|
||||
}
|
||||
|
||||
tooltip += `<div><span class="chart-pointer" style="background-color: #${DEFAULT_CHART_COLORS[index]}"></span>`
|
||||
+ `<span>${displayItem.name}</span><span class="ms-5" style="float: inline-end">${displayItem.value}</span><br/>`
|
||||
+ `<span>${displayItem.name}</span><span class="ms-5" style="float: inline-end">${displayItem.value}</span>`
|
||||
+ `</div>`;
|
||||
}
|
||||
|
||||
@@ -217,7 +285,7 @@ const chartOptions = computed<object>(() => {
|
||||
|
||||
return `${params[0]!.name}<br/>`
|
||||
+ '<div><span class="chart-pointer" style="background-color: #' + DEFAULT_CHART_COLORS[0] + '"></span>'
|
||||
+ `<span>${props.legendName}</span><span class="ms-5" style="float: inline-end">${value}</span><br/>`
|
||||
+ `<span>${props.legendName}</span><span class="ms-5" style="float: inline-end">${value}</span>`
|
||||
+ '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ interface AxisChartDataItem {
|
||||
}
|
||||
|
||||
interface AxisChartTooltipItem extends SortableTransactionStatisticDataItem {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly color: unknown;
|
||||
readonly displayOrders: number[];
|
||||
@@ -71,6 +72,9 @@ const props = defineProps<{
|
||||
amountValue?: boolean;
|
||||
defaultCurrency?: string;
|
||||
enableClickItem?: boolean;
|
||||
tooltipExtraColumnNames?: string[];
|
||||
tooltipExtraColumnTotalValues?: (categoryIndex: number, totalValue: number, visibleSeriesIds: string[]) => string[];
|
||||
tooltipExtraColumnValues?: (seriesId: string, categoryIndex: number, currentValue: number) => string[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -85,6 +89,7 @@ const {
|
||||
formatAmountToWesternArabicNumeralsWithoutDigitGrouping,
|
||||
formatAmountToLocalizedNumeralsWithCurrency,
|
||||
formatNumberToLocalizedNumerals,
|
||||
formatNumberToWesternArabicNumerals,
|
||||
formatPercentToLocalizedNumerals
|
||||
} = useI18n();
|
||||
|
||||
@@ -289,6 +294,8 @@ const chartOptions = computed<object>(() => {
|
||||
let totalAmount = 0;
|
||||
let actualDisplayItemCount = 0;
|
||||
const displayItems: AxisChartTooltipItem[] = [];
|
||||
const categoryIndex = params.length > 0 && params[0] ? (params[0].dataIndex ?? 0) : 0;
|
||||
const visibleSeriesIds: string[] = [];
|
||||
|
||||
for (const param of params) {
|
||||
const id = param.seriesId as string;
|
||||
@@ -298,37 +305,111 @@ const chartOptions = computed<object>(() => {
|
||||
const amount = param.data as number;
|
||||
|
||||
displayItems.push({
|
||||
id: id,
|
||||
name: name,
|
||||
color: color,
|
||||
displayOrders: displayOrders,
|
||||
totalAmount: amount
|
||||
});
|
||||
|
||||
visibleSeriesIds.push(id);
|
||||
totalAmount += amount;
|
||||
}
|
||||
|
||||
sortStatisticsItems(displayItems, props.sortingType);
|
||||
|
||||
for (const item of displayItems) {
|
||||
const extraColumnValuesMap: Record<number, string[]> = {};
|
||||
const extraColumnTotalValues: string[] = [];
|
||||
const hasExtraColumnIndexes: Record<number, boolean> = {};
|
||||
|
||||
if (props.tooltipExtraColumnNames) {
|
||||
if (props.tooltipExtraColumnValues) {
|
||||
for (const [item, index] of itemAndIndex(displayItems)) {
|
||||
const values = props.tooltipExtraColumnValues(item.id, categoryIndex, item.totalAmount);
|
||||
extraColumnValuesMap[index] = values;
|
||||
|
||||
for (const [value, columnIndex] of itemAndIndex(values)) {
|
||||
if (value && value !== '-') {
|
||||
hasExtraColumnIndexes[columnIndex] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (props.tooltipExtraColumnTotalValues) {
|
||||
const values = props.tooltipExtraColumnTotalValues(categoryIndex, totalAmount, visibleSeriesIds);
|
||||
extraColumnTotalValues.push(...values);
|
||||
|
||||
for (const [value, columnIndex] of itemAndIndex(values)) {
|
||||
if (value && value !== '-') {
|
||||
hasExtraColumnIndexes[columnIndex] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [item, index] of itemAndIndex(displayItems)) {
|
||||
if (displayItems.length === 1 || item.totalAmount !== 0) {
|
||||
const value = getDisplayValue(item.totalAmount);
|
||||
tooltip += '<div><span class="chart-pointer" style="background-color: ' + item.color + '"></span>';
|
||||
tooltip += `<span>${item.name}</span><span class="ms-5" style="float: inline-end">${value}</span><br/>`;
|
||||
tooltip += '</div>';
|
||||
tooltip += '<tr><td><span class="chart-pointer" style="background-color: ' + item.color + '"></span>';
|
||||
tooltip += `<span>${item.name}</span></td><td><span class="ms-5" style="float: inline-end">${value}</span></td>`;
|
||||
|
||||
if (props.tooltipExtraColumnNames) {
|
||||
const values = extraColumnValuesMap[index] ?? [];
|
||||
|
||||
for (let i = 0; i < props.tooltipExtraColumnNames.length; i++) {
|
||||
if (!hasExtraColumnIndexes[i]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const value = values[i] ?? '-';
|
||||
tooltip += `<td><span class="ms-5" style="float: inline-end">${value}</span></td>`;
|
||||
}
|
||||
}
|
||||
|
||||
tooltip += '</tr>';
|
||||
actualDisplayItemCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (props.showTotalAmountInTooltip && !props.oneHundredPercentStacked) {
|
||||
const displayTotalAmount = getDisplayValue(totalAmount);
|
||||
tooltip = (actualDisplayItemCount > 0 ? '<div style="border-bottom: ' + (isDarkMode.value ? '#eee' : '#333') + ' dashed 1px">' : '<div></div>')
|
||||
+ '<span class="chart-pointer" style="background-color: ' + (isDarkMode.value ? '#eee' : '#333') + '"></span>'
|
||||
+ `<span>${props.totalNameInTooltip}</span><span class="ms-5" style="float: inline-end">${displayTotalAmount}</span><br/>`
|
||||
+ '</div>' + tooltip;
|
||||
let totalColumnCount = 2;
|
||||
|
||||
let totalTooltip = `<tr><td><span class="chart-pointer" style="background-color: ${isDarkMode.value ? '#eee' : '#333'}"></span>`
|
||||
+ `<span>${props.totalNameInTooltip}</span></td><td><span class="ms-5" style="float: inline-end">${displayTotalAmount}</span></td>`;
|
||||
|
||||
if (props.tooltipExtraColumnNames) {
|
||||
for (let i = 0; i < props.tooltipExtraColumnNames.length; i++) {
|
||||
if (!hasExtraColumnIndexes[i]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const value = extraColumnTotalValues[i] ?? '-';
|
||||
totalTooltip += `<td><span class="ms-5" style="float: inline-end">${value}</span></td>`;
|
||||
totalColumnCount++;
|
||||
}
|
||||
}
|
||||
|
||||
totalTooltip += '</tr>';
|
||||
totalTooltip += `<tr><td colspan="${totalColumnCount}" ${actualDisplayItemCount > 0 ? 'style="border-bottom: ' + (isDarkMode.value ? '#eee' : '#333') + ' dashed 1px"' : ''}></td></tr>`;
|
||||
tooltip = totalTooltip + tooltip;
|
||||
}
|
||||
|
||||
if (params.length && params[0] && params[0].name) {
|
||||
tooltip = `${params[0].name}<br/>` + tooltip;
|
||||
let tooltipHeader = `<td>${params[0].name}</td><td></td>`;
|
||||
|
||||
if (props.tooltipExtraColumnNames) {
|
||||
for (const [columnName, columnIndex] of itemAndIndex(props.tooltipExtraColumnNames)) {
|
||||
if (!hasExtraColumnIndexes[columnIndex]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
tooltipHeader += `<td><span class="ms-5" style="float: inline-end">${columnName}</span></td>`;
|
||||
}
|
||||
}
|
||||
|
||||
tooltip = `<table class="chart-tooltip-table"><tbody><tr>${tooltipHeader}</tr>${tooltip}</tbody></table>`
|
||||
}
|
||||
|
||||
return tooltip;
|
||||
@@ -404,7 +485,7 @@ function getDisplayValue(value: number): string {
|
||||
return formatAmountToLocalizedNumeralsWithCurrency(value, props.defaultCurrency);
|
||||
}
|
||||
|
||||
return formatNumberToLocalizedNumerals(value);
|
||||
return formatNumberToLocalizedNumerals(value, 2);
|
||||
}
|
||||
|
||||
function clickItem(e: ECElementEvent): void {
|
||||
@@ -439,7 +520,13 @@ function exportData(): { headers: string[], data: string[][] } {
|
||||
for (const [categoryName, index] of itemAndIndex(props.allCategoryNames)) {
|
||||
const row: string[] = [];
|
||||
row.push(categoryName);
|
||||
row.push(...allSeries.value.map(item => formatAmountToWesternArabicNumeralsWithoutDigitGrouping(item.data[index] ?? 0)));
|
||||
row.push(...allSeries.value.map(item => {
|
||||
if (props.oneHundredPercentStacked) {
|
||||
return formatNumberToWesternArabicNumerals(item.data[index] ?? 0);
|
||||
} else {
|
||||
return formatAmountToWesternArabicNumeralsWithoutDigitGrouping(item.data[index] ?? 0);
|
||||
}
|
||||
}));
|
||||
data.push(row);
|
||||
}
|
||||
|
||||
|
||||
@@ -89,6 +89,7 @@ import { ThemeType } from '@/core/theme.ts';
|
||||
import { NumeralSystem } from '@/core/numeral.ts';
|
||||
import {
|
||||
type DateTime,
|
||||
type DateFormatOrder,
|
||||
MeridiemIndicator,
|
||||
KnownDateTimeFormat
|
||||
} from '@/core/datetime.ts';
|
||||
@@ -120,6 +121,8 @@ const theme = useTheme();
|
||||
const {
|
||||
tt,
|
||||
getCurrentNumeralSystemType,
|
||||
getLongDateFormatOrder,
|
||||
getShortDateFormatOrder,
|
||||
parseDateTimeFromLongDateTime,
|
||||
parseDateTimeFromShortDateTime,
|
||||
formatDateTimeToLongDateTime
|
||||
@@ -144,6 +147,8 @@ const secondInput = useTemplateRef<VAutocomplete>('secondInput');
|
||||
|
||||
const isDarkMode = computed<boolean>(() => theme.global.name.value === ThemeType.Dark);
|
||||
const numeralSystem = computed<NumeralSystem>(() => getCurrentNumeralSystemType());
|
||||
const longDateFormatOrder = computed<DateFormatOrder>(() => getLongDateFormatOrder());
|
||||
const shortDateFormatOrder = computed<DateFormatOrder>(() => getShortDateFormatOrder());
|
||||
|
||||
const dateTime = computed<Date>({
|
||||
get: () => {
|
||||
@@ -245,10 +250,10 @@ function onPaste(event: ClipboardEvent): void {
|
||||
|
||||
text = text.trim();
|
||||
|
||||
const formats = KnownDateTimeFormat.detect(text);
|
||||
const formats = KnownDateTimeFormat.detect(text, longDateFormatOrder.value, shortDateFormatOrder.value);
|
||||
let dt: DateTime | undefined = undefined;
|
||||
|
||||
if (formats && formats.length === 1) {
|
||||
if (formats && (formats.length === 1 || (formats.length > 1 && formats[0]!.type === longDateFormatOrder.value && formats[0]!.type === shortDateFormatOrder.value))) {
|
||||
dt = parseDateTimeFromKnownDateTimeFormat(text, formats[0] as KnownDateTimeFormat);
|
||||
|
||||
if (dt) {
|
||||
|
||||
@@ -84,7 +84,7 @@ const radarData = computed<RadarChartData>(() => {
|
||||
|
||||
const finalPercent = (isNumber(percent) && percent >= 0) ? percent : (value > 0 ? value / totalValidValue * 100 : 0);
|
||||
const displayPercent = formatPercentToLocalizedNumerals(finalPercent, 2, '<0.01');
|
||||
const displayValue = props.amountValue ? formatAmountToLocalizedNumeralsWithCurrency(value, props.defaultCurrency) : formatNumberToLocalizedNumerals(value);
|
||||
const displayValue = props.amountValue ? formatAmountToLocalizedNumeralsWithCurrency(value, props.defaultCurrency) : formatNumberToLocalizedNumerals(value, 2);
|
||||
|
||||
indicators.push({
|
||||
name: name,
|
||||
|
||||
@@ -9,6 +9,9 @@
|
||||
:translate-name="translateName"
|
||||
:amount-value="true" :default-currency="defaultCurrency"
|
||||
:enable-click-item="enableClickItem"
|
||||
:tooltip-extra-column-names="allTooltipExtraColumnNames"
|
||||
:tooltip-extra-column-total-values="showYearOverYear || showPeriodOverPeriod ? getTooltipExtraColumnTotalValues : undefined"
|
||||
:tooltip-extra-column-values="showYearOverYear || showPeriodOverPeriod ? getTooltipExtraColumnValues : undefined"
|
||||
@click="clickItem"
|
||||
v-if="chartDisplayType"
|
||||
/>
|
||||
@@ -29,18 +32,31 @@ import {
|
||||
|
||||
import { useUserStore } from '@/stores/user.ts';
|
||||
|
||||
import {
|
||||
itemAndIndex
|
||||
} from '@/core/base.ts';
|
||||
import {
|
||||
type Year1BasedMonth,
|
||||
type YearMonthDay,
|
||||
type YearUnixTime,
|
||||
type YearQuarterUnixTime,
|
||||
type YearMonthUnixTime,
|
||||
type YearMonthDayUnixTime,
|
||||
DateRangeScene
|
||||
} from '@/core/datetime.ts';
|
||||
import {
|
||||
type FiscalYearUnixTime
|
||||
} from '@/core/fiscalyear.ts';
|
||||
import {
|
||||
ChartDataAggregationType,
|
||||
TrendChartType,
|
||||
ChartDateAggregationType
|
||||
} from '@/core/statistics.ts';
|
||||
|
||||
import { isArray, isNumber } from '@/lib/common.ts';
|
||||
import {
|
||||
isArray,
|
||||
isNumber
|
||||
} from '@/lib/common.ts';
|
||||
import {
|
||||
parseDateTimeFromUnixTime,
|
||||
getYearMonthFirstUnixTime,
|
||||
@@ -56,6 +72,8 @@ interface DesktopTrendsChartProps<T extends TrendsChartDateType> extends CommonT
|
||||
type?: number;
|
||||
showValue?: boolean;
|
||||
showTotalAmountInTooltip?: boolean;
|
||||
showYearOverYear?: boolean;
|
||||
showPeriodOverPeriod?: boolean;
|
||||
}
|
||||
|
||||
const props = defineProps<DesktopTrendsChartProps<TrendsChartDateType>>();
|
||||
@@ -70,7 +88,8 @@ const {
|
||||
formatDateTimeToGregorianLikeShortYear,
|
||||
formatDateTimeToGregorianLikeShortYearMonth,
|
||||
formatYearQuarterToGregorianLikeYearQuarter,
|
||||
formatDateTimeToGregorianLikeFiscalYear
|
||||
formatDateTimeToGregorianLikeFiscalYear,
|
||||
formatPercentToLocalizedNumerals
|
||||
} = useI18n();
|
||||
|
||||
const { allDateRanges } = useTrendsChartBase(props);
|
||||
@@ -91,6 +110,20 @@ const chartDisplayType = computed<AxisChartDisplayType | undefined>(() => {
|
||||
}
|
||||
});
|
||||
|
||||
const allTooltipExtraColumnNames = computed<string[]>(() => {
|
||||
const extraColumnNames: string[] = [];
|
||||
|
||||
if (props.showYearOverYear) {
|
||||
extraColumnNames.push(tt('Year-over-Year'));
|
||||
}
|
||||
|
||||
if (props.showPeriodOverPeriod) {
|
||||
extraColumnNames.push(tt('Period-over-Period'));
|
||||
}
|
||||
|
||||
return extraColumnNames;
|
||||
});
|
||||
|
||||
const allDisplayDateRanges = computed<string[]>(() => {
|
||||
const allDisplayDateRanges: string[] = [];
|
||||
|
||||
@@ -188,22 +221,9 @@ const allSeriesData = computed<Record<string, unknown>[]>(() => {
|
||||
}
|
||||
|
||||
for (const dateRange of allDateRanges.value) {
|
||||
let dateRangeKey = '';
|
||||
|
||||
if (props.dateAggregationType === ChartDateAggregationType.Year.type) {
|
||||
dateRangeKey = dateRange.year.toString();
|
||||
} else if (props.dateAggregationType === ChartDateAggregationType.FiscalYear.type && 'year' in dateRange) {
|
||||
dateRangeKey = dateRange.year.toString();
|
||||
} else if (props.dateAggregationType === ChartDateAggregationType.Quarter.type && 'quarter' in dateRange) {
|
||||
dateRangeKey = `${dateRange.year}-${dateRange.quarter}`;
|
||||
} else if (props.dateAggregationType === ChartDateAggregationType.Month.type && 'month0base' in dateRange) {
|
||||
dateRangeKey = `${dateRange.year}-${dateRange.month0base + 1}`;
|
||||
} else if (props.dateAggregationType === ChartDateAggregationType.Day.type && 'day' in dateRange && props.chartMode === 'daily') {
|
||||
dateRangeKey = `${dateRange.year}-${dateRange.month}-${dateRange.day}`;
|
||||
}
|
||||
|
||||
let amount = 0;
|
||||
const dateRangeKey = getDateRangeKey(dateRange) ?? '';
|
||||
const dataItems = dateRangeAmountMap[dateRangeKey];
|
||||
let amount = 0;
|
||||
|
||||
if (isArray(dataItems)) {
|
||||
for (const dataItem of dataItems) {
|
||||
@@ -229,6 +249,172 @@ const allSeriesData = computed<Record<string, unknown>[]>(() => {
|
||||
return result;
|
||||
});
|
||||
|
||||
const seriesIdValuesMap = computed<Record<string, number[]>>(() => {
|
||||
const result: Record<string, number[]> = {};
|
||||
|
||||
for (const item of allSeriesData.value) {
|
||||
const id = getSeriesId(item);
|
||||
const values = item['values'] as number[];
|
||||
|
||||
if (id && values) {
|
||||
result[id] = values;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
const yoyIndexMap = computed<Record<number, number>>(() => {
|
||||
const result: Record<number, number> = {};
|
||||
const dateKeyToIndex: Record<string, number> = {};
|
||||
|
||||
for (const [dateRange, index] of itemAndIndex(allDateRanges.value)) {
|
||||
const key = getDateRangeKey(dateRange);
|
||||
|
||||
if (key) {
|
||||
dateKeyToIndex[key] = index;
|
||||
}
|
||||
}
|
||||
|
||||
for (const [dateRange, index] of itemAndIndex(allDateRanges.value)) {
|
||||
const yoyKey = getDateRangeKey(dateRange, -1);
|
||||
|
||||
if (yoyKey && isNumber(dateKeyToIndex[yoyKey])) {
|
||||
result[index] = dateKeyToIndex[yoyKey];
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
function getSeriesId(item: Record<string, unknown>): string {
|
||||
if (props.idField && item[props.idField]) {
|
||||
return item[props.idField] as string;
|
||||
}
|
||||
|
||||
const name = item[props.nameField] as string;
|
||||
return props.translateName ? tt(name) : name;
|
||||
}
|
||||
|
||||
function getDateRangeKey(dateRange: YearUnixTime | FiscalYearUnixTime | YearQuarterUnixTime | YearMonthUnixTime | YearMonthDayUnixTime, yearOffset?: number): string | undefined {
|
||||
if (props.dateAggregationType === ChartDateAggregationType.Year.type) {
|
||||
return (dateRange.year + (yearOffset ?? 0)).toString();
|
||||
} else if (props.dateAggregationType === ChartDateAggregationType.FiscalYear.type && 'year' in dateRange) {
|
||||
return (dateRange.year + (yearOffset ?? 0)).toString();
|
||||
} else if (props.dateAggregationType === ChartDateAggregationType.Quarter.type && 'quarter' in dateRange) {
|
||||
return `${dateRange.year + (yearOffset ?? 0)}-${dateRange.quarter}`;
|
||||
} else if (props.dateAggregationType === ChartDateAggregationType.Month.type && 'month0base' in dateRange) {
|
||||
return `${dateRange.year + (yearOffset ?? 0)}-${dateRange.month0base + 1}`;
|
||||
} else if (props.dateAggregationType === ChartDateAggregationType.Day.type && 'day' in dateRange && props.chartMode === 'daily') {
|
||||
return `${dateRange.year + (yearOffset ?? 0)}-${dateRange.month}-${dateRange.day}`;
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDisplayChangeRate(current: number, reference: number): string {
|
||||
if (reference === 0 && current === 0) {
|
||||
return formatPercentToLocalizedNumerals(0, 2, '<0.01');
|
||||
}
|
||||
|
||||
if (reference === 0) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
const rate = (current - reference) / reference * 100;
|
||||
return formatPercentToLocalizedNumerals(rate, 2, '<0.01');
|
||||
}
|
||||
|
||||
function getTooltipExtraColumnTotalValues(categoryIndex: number, totalValue: number, visibleSeriesIds: string[]): string[] {
|
||||
const extraColumnValues: string[] = [];
|
||||
|
||||
if (!props.showYearOverYear && !props.showPeriodOverPeriod) {
|
||||
return extraColumnValues;
|
||||
}
|
||||
|
||||
if (props.showYearOverYear) {
|
||||
const yoyReferenceIndex = yoyIndexMap.value[categoryIndex];
|
||||
let displayChangeRate = '-';
|
||||
|
||||
if (isNumber(yoyReferenceIndex)) {
|
||||
let referenceTotalValue = 0;
|
||||
|
||||
for (const seriesId of visibleSeriesIds) {
|
||||
const values = seriesIdValuesMap.value[seriesId];
|
||||
|
||||
if (values) {
|
||||
referenceTotalValue += values[yoyReferenceIndex] ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
displayChangeRate = formatDisplayChangeRate(totalValue, referenceTotalValue);
|
||||
}
|
||||
|
||||
extraColumnValues.push(displayChangeRate);
|
||||
}
|
||||
|
||||
if (props.showPeriodOverPeriod) {
|
||||
const popReferenceIndex = categoryIndex - 1;
|
||||
let displayChangeRate = '-';
|
||||
|
||||
if (popReferenceIndex >= 0) {
|
||||
let referenceTotalValue = 0;
|
||||
|
||||
for (const seriesId of visibleSeriesIds) {
|
||||
const values = seriesIdValuesMap.value[seriesId];
|
||||
|
||||
if (values) {
|
||||
referenceTotalValue += values[popReferenceIndex] ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
displayChangeRate = formatDisplayChangeRate(totalValue, referenceTotalValue);
|
||||
}
|
||||
|
||||
extraColumnValues.push(displayChangeRate);
|
||||
}
|
||||
|
||||
return extraColumnValues;
|
||||
}
|
||||
|
||||
function getTooltipExtraColumnValues(seriesId: string, categoryIndex: number, currentValue: number): string[] {
|
||||
const extraColumnValues: string[] = [];
|
||||
|
||||
if (!props.showYearOverYear && !props.showPeriodOverPeriod) {
|
||||
return extraColumnValues;
|
||||
}
|
||||
|
||||
const values = seriesIdValuesMap.value[seriesId];
|
||||
|
||||
if (!values) {
|
||||
return extraColumnValues;
|
||||
}
|
||||
|
||||
if (props.showYearOverYear) {
|
||||
const yoyReferenceIndex = yoyIndexMap.value[categoryIndex];
|
||||
let displayChangeRate = '-';
|
||||
|
||||
if (isNumber(yoyReferenceIndex) && yoyReferenceIndex >= 0 && yoyReferenceIndex < values.length) {
|
||||
displayChangeRate = formatDisplayChangeRate(currentValue, values[yoyReferenceIndex] ?? 0);
|
||||
}
|
||||
|
||||
extraColumnValues.push(displayChangeRate);
|
||||
}
|
||||
|
||||
if (props.showPeriodOverPeriod) {
|
||||
const popReferenceIndex = categoryIndex - 1;
|
||||
let displayChangeRate = '-';
|
||||
|
||||
if (popReferenceIndex >= 0 && popReferenceIndex < values.length) {
|
||||
displayChangeRate = formatDisplayChangeRate(currentValue, values[popReferenceIndex] ?? 0);
|
||||
}
|
||||
|
||||
extraColumnValues.push(displayChangeRate);
|
||||
}
|
||||
|
||||
return extraColumnValues;
|
||||
}
|
||||
|
||||
function clickItem(itemId: string, categoryIndex: number): void {
|
||||
const dateRange = allDateRanges.value[categoryIndex];
|
||||
|
||||
|
||||
@@ -70,12 +70,12 @@ const cancelRecognizingUuid = ref<string | undefined>(undefined);
|
||||
const imageFile = ref<File | null>(null);
|
||||
const imageSrc = ref<string | undefined>(undefined);
|
||||
|
||||
function loadImage(file: File): void {
|
||||
function loadImage(image: Blob): void {
|
||||
loading.value = true;
|
||||
imageFile.value = null;
|
||||
imageSrc.value = undefined;
|
||||
|
||||
compressJpgImage(file, 1280, 1280, 0.8).then(blob => {
|
||||
compressJpgImage(image, 1280, 1280, 0.8).then(blob => {
|
||||
imageFile.value = KnownFileType.JPG.createFileFromBlob(blob, "image");
|
||||
imageSrc.value = URL.createObjectURL(blob);
|
||||
loading.value = false;
|
||||
@@ -184,6 +184,10 @@ function onSheetOpen(): void {
|
||||
function onSheetClosed(): void {
|
||||
close();
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
loadImage
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -103,6 +103,8 @@ const allVirtualListItems = computed<MobileAccountBalanceTrendsChartItem[]>(() =
|
||||
averageBalance: dataItem.averageBalance,
|
||||
minimumBalance: dataItem.minimumBalance,
|
||||
maximumBalance: dataItem.maximumBalance,
|
||||
q1Balance: dataItem.q1Balance,
|
||||
q3Balance: dataItem.q3Balance,
|
||||
color: `#${DEFAULT_CHART_COLORS[0] as string}`,
|
||||
percent: 0.0
|
||||
};
|
||||
|
||||
@@ -365,7 +365,8 @@ function paste(): void {
|
||||
|
||||
currentValue.value = getStringValue(parsedAmount, false);
|
||||
}).catch(error => {
|
||||
pastingAmount.value = false;
|
||||
// Do not set pastingAmount to false here
|
||||
// In iOS, system will show the paste context menu, if user click outside, the paste action should not be triggered again
|
||||
logger.error('failed to read clipboard text', error);
|
||||
});
|
||||
}
|
||||
|
||||
+1
-1
@@ -78,7 +78,7 @@ export const SPECIFIED_API_NOT_FOUND_ERRORS: Record<string, SpecifiedApiError> =
|
||||
'/api/v1/users/2fa/recovery/regenerate.json': {
|
||||
message: 'Two-factor authentication is disabled'
|
||||
},
|
||||
'/api/v1/transactions/parse_dsv_file.json': {
|
||||
'/api/v1/transactions/parse_custom_file.json': {
|
||||
message: 'Transaction importing is disabled'
|
||||
},
|
||||
'/api/v1/transactions/parse_import.json': {
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
export const SW_PRECACHE_CACHE_NAME_PREFIX: string = 'workbox-precache-v2-';
|
||||
export const SW_RUNTIME_CACHE_NAME_PREFIX: string = 'workbox-runtime-';
|
||||
export const SW_ASSETS_CACHE_NAME: string = 'ezbookkeeping-assets-cache';
|
||||
export const SW_CODE_CACHE_NAME: string = 'ezbookkeeping-code-cache';
|
||||
export const SW_MAP_CACHE_NAME: string = 'ezbookkeeping-map-cache';
|
||||
export const SW_SHARE_CACHE_NAME: string = 'ezbookkeeping-share-cache';
|
||||
|
||||
export const SW_MESSAGE_TYPE_UPDATE_MAP_CACHE_CONFIG: string = 'UPDATE_MAP_CACHE_CONFIG';
|
||||
export const SW_MESSAGE_TYPE_UPDATE_MAP_CACHE_CONFIG_RESPONSE: string = 'UPDATE_MAP_CACHE_CONFIG_RESPONSE';
|
||||
|
||||
export const MAP_CACHE_MAX_ENTRIES: number = 1000;
|
||||
+41
-6
@@ -13,11 +13,10 @@ export const UTF_8 = 'utf-8';
|
||||
|
||||
export const SUPPORTED_FILE_ENCODINGS: string[] = [
|
||||
UTF_8, // UTF-8
|
||||
'utf-8-bom', // UTF-8 with BOM
|
||||
'utf-16le', // UTF-16 Little Endian
|
||||
'utf-16be', // UTF-16 Big Endian
|
||||
'utf-16le-bom', // UTF-16 Little Endian with BOM
|
||||
'utf-16be-bom', // UTF-16 Big Endian with BOM
|
||||
'utf-32le', // UTF-32 Little Endian
|
||||
'utf-32be', // UTF-32 Big Endian
|
||||
'cp437', // OEM United States (CP-437)
|
||||
'cp863', // OEM Canadian French (CP-863)
|
||||
'cp037', // IBM EBCDIC US/Canada (CP-037)
|
||||
@@ -70,8 +69,8 @@ export const CHARDET_ENCODING_NAME_MAPPING: Record<string, string> = {
|
||||
'UTF-8': UTF_8,
|
||||
'UTF-16LE': 'utf-16le',
|
||||
'UTF-16BE': 'utf-16be',
|
||||
// 'UTF-32 LE': '', // not supported
|
||||
// 'UTF-32 BE': '', // not supported
|
||||
'UTF-32LE': 'utf-32le',
|
||||
'UTF-32BE': 'utf-32be',
|
||||
'ISO-2022-JP': 'iso-2022-jp',
|
||||
// 'ISO-2022-KR': '', // not supported
|
||||
// 'ISO-2022-CN': '', // not supported
|
||||
@@ -141,6 +140,11 @@ export const SUPPORTED_IMPORT_FILE_CATEGORY_AND_TYPES: ImportFileCategoryAndType
|
||||
type: 'custom_tsv',
|
||||
name: 'TSV (Tab-separated values) File',
|
||||
extensions: '.tsv,.txt',
|
||||
},
|
||||
{
|
||||
type: 'custom_ssv',
|
||||
name: 'SSV (Semicolon-separated values) File',
|
||||
extensions: '.txt',
|
||||
}
|
||||
],
|
||||
supportedEncodings: SUPPORTED_FILE_ENCODINGS,
|
||||
@@ -163,6 +167,11 @@ export const SUPPORTED_IMPORT_FILE_CATEGORY_AND_TYPES: ImportFileCategoryAndType
|
||||
type: 'custom_tsv',
|
||||
name: 'TSV (Tab-separated values) File',
|
||||
extensions: '.tsv,.txt',
|
||||
},
|
||||
{
|
||||
type: 'custom_ssv',
|
||||
name: 'SSV (Semicolon-separated values) File',
|
||||
extensions: '.txt',
|
||||
}
|
||||
],
|
||||
dataFromTextbox: true,
|
||||
@@ -170,7 +179,28 @@ export const SUPPORTED_IMPORT_FILE_CATEGORY_AND_TYPES: ImportFileCategoryAndType
|
||||
supportMultiLanguages: true,
|
||||
anchor: 'how-to-import-delimiter-separated-values-dsv-file-or-data'
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'excel',
|
||||
name: 'Excel Workbook File',
|
||||
extensions: '.xlsx,.xls',
|
||||
subTypes: [
|
||||
{
|
||||
type: 'custom_xlsx',
|
||||
name: 'Excel Workbook File (.xlsx)',
|
||||
extensions: '.xlsx',
|
||||
},
|
||||
{
|
||||
type: 'custom_xls',
|
||||
name: 'Excel 97-2003 Workbook File (.xls)',
|
||||
extensions: '.xls',
|
||||
}
|
||||
],
|
||||
document: {
|
||||
supportMultiLanguages: true,
|
||||
anchor: 'how-to-import-delimiter-separated-values-dsv-file-or-data'
|
||||
}
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -219,6 +249,11 @@ export const SUPPORTED_IMPORT_FILE_CATEGORY_AND_TYPES: ImportFileCategoryAndType
|
||||
{
|
||||
categoryName: 'General Bank Statement Format',
|
||||
fileTypes: [
|
||||
{
|
||||
type: 'camt052',
|
||||
name: 'Camt.052 Bank to Customer Statement File',
|
||||
extensions: '.xml'
|
||||
},
|
||||
{
|
||||
type: 'camt053',
|
||||
name: 'Camt.053 Bank to Customer Statement File',
|
||||
|
||||
@@ -158,6 +158,9 @@ export const ALL_ACCOUNT_ICONS: Record<string, IconInfo> = {
|
||||
},
|
||||
'8302': {
|
||||
icon: 'lab la-weixin'
|
||||
},
|
||||
'8303': {
|
||||
icon: 'lab la-line'
|
||||
}
|
||||
};
|
||||
|
||||
@@ -257,6 +260,12 @@ export const ALL_CATEGORY_ICONS: Record<string, IconInfo> = {
|
||||
'200': {
|
||||
icon: 'las la-home'
|
||||
},
|
||||
'201': {
|
||||
icon: 'las la-store'
|
||||
},
|
||||
'202': {
|
||||
icon: 'las la-store-alt'
|
||||
},
|
||||
'210': {
|
||||
icon: 'las la-toilet-paper'
|
||||
},
|
||||
@@ -305,6 +314,9 @@ export const ALL_CATEGORY_ICONS: Record<string, IconInfo> = {
|
||||
'252': {
|
||||
icon: 'las la-toolbox'
|
||||
},
|
||||
'253': {
|
||||
icon: 'las la-paint-roller'
|
||||
},
|
||||
'260': {
|
||||
icon: 'las la-broom'
|
||||
},
|
||||
@@ -400,6 +412,9 @@ export const ALL_CATEGORY_ICONS: Record<string, IconInfo> = {
|
||||
'442': {
|
||||
icon: 'las la-satellite'
|
||||
},
|
||||
'443': {
|
||||
icon: 'las la-ethernet'
|
||||
},
|
||||
'450': {
|
||||
icon: 'las la-tv'
|
||||
},
|
||||
@@ -418,6 +433,9 @@ export const ALL_CATEGORY_ICONS: Record<string, IconInfo> = {
|
||||
'480': {
|
||||
icon: 'las la-shipping-fast'
|
||||
},
|
||||
'490': {
|
||||
icon: 'las la-language'
|
||||
},
|
||||
// 500 - 599 : Expense - Entertainment
|
||||
'500': {
|
||||
icon: 'las la-heart'
|
||||
@@ -449,6 +467,9 @@ export const ALL_CATEGORY_ICONS: Record<string, IconInfo> = {
|
||||
'518': {
|
||||
icon: 'las la-hiking'
|
||||
},
|
||||
'519': {
|
||||
icon: 'las la-compass'
|
||||
},
|
||||
'520': {
|
||||
icon: 'las la-futbol'
|
||||
},
|
||||
@@ -533,6 +554,9 @@ export const ALL_CATEGORY_ICONS: Record<string, IconInfo> = {
|
||||
'582': {
|
||||
icon: 'las la-crow'
|
||||
},
|
||||
'583': {
|
||||
icon: 'las la-cat'
|
||||
},
|
||||
'589': {
|
||||
icon: 'las la-bone'
|
||||
},
|
||||
@@ -644,6 +668,9 @@ export const ALL_CATEGORY_ICONS: Record<string, IconInfo> = {
|
||||
'880': {
|
||||
icon: 'las la-glasses'
|
||||
},
|
||||
'881': {
|
||||
icon: 'las la-wheelchair'
|
||||
},
|
||||
'890': {
|
||||
icon: 'las la-thermometer'
|
||||
},
|
||||
@@ -694,6 +721,33 @@ export const ALL_CATEGORY_ICONS: Record<string, IconInfo> = {
|
||||
'1010': {
|
||||
icon: 'las la-minus-circle'
|
||||
},
|
||||
'1020': {
|
||||
icon: 'lab la-dev'
|
||||
},
|
||||
'1021': {
|
||||
icon: 'las la-laptop-code'
|
||||
},
|
||||
'1022': {
|
||||
icon: 'las la-server'
|
||||
},
|
||||
'1023': {
|
||||
icon: 'las la-hdd'
|
||||
},
|
||||
'1024': {
|
||||
icon: 'las la-memory'
|
||||
},
|
||||
'1025': {
|
||||
icon: 'las la-microchip'
|
||||
},
|
||||
'1026': {
|
||||
icon: 'las la-robot'
|
||||
},
|
||||
'1027': {
|
||||
icon: 'las la-link'
|
||||
},
|
||||
'1100': {
|
||||
icon: 'las la-seedling'
|
||||
},
|
||||
// 2000 - 2099 : Income - Occupational Earnings
|
||||
'2000': {
|
||||
icon: 'las la-suitcase'
|
||||
@@ -769,6 +823,9 @@ export const ALL_CATEGORY_ICONS: Record<string, IconInfo> = {
|
||||
'6001': {
|
||||
icon: 'lab la-ebay'
|
||||
},
|
||||
'6010': {
|
||||
icon: 'lab la-line'
|
||||
},
|
||||
'6100': {
|
||||
icon: 'lab la-app-store'
|
||||
},
|
||||
@@ -784,6 +841,9 @@ export const ALL_CATEGORY_ICONS: Record<string, IconInfo> = {
|
||||
'6400': {
|
||||
icon: 'lab la-uber'
|
||||
},
|
||||
'6410': {
|
||||
icon: 'lab la-airbnb'
|
||||
},
|
||||
'6500': {
|
||||
icon: 'lab la-fedex'
|
||||
},
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import { entries } from '@/core/base.ts';
|
||||
import { TransactionType } from '@/core/transaction.ts';
|
||||
import { ImportTransactionColumnType } from '@/core/import_transaction.ts';
|
||||
|
||||
export const KNOWN_COLUMN_NAME_MAPPING: Record<string, ImportTransactionColumnType> = ((mappings: Record<string, ImportTransactionColumnType>[]) => {
|
||||
const result: Record<string, ImportTransactionColumnType> = {};
|
||||
|
||||
for (const mapping of mappings) {
|
||||
for (const [key, value] of entries(mapping)) {
|
||||
const normalizedKey = key.toLowerCase().replaceAll(' ', '').replaceAll('_', '').replaceAll('-', '');
|
||||
|
||||
if (result[normalizedKey]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
result[normalizedKey] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
})([
|
||||
// Columns of ezbookkeeping Data Export File
|
||||
{
|
||||
['Time']: ImportTransactionColumnType.TransactionTime,
|
||||
['Timezone']: ImportTransactionColumnType.TransactionTimezone,
|
||||
['Type']: ImportTransactionColumnType.TransactionType,
|
||||
['Category']: ImportTransactionColumnType.Category,
|
||||
['Sub Category']: ImportTransactionColumnType.SubCategory,
|
||||
['Account']: ImportTransactionColumnType.AccountName,
|
||||
['Account Currency']: ImportTransactionColumnType.AccountCurrency,
|
||||
['Amount']: ImportTransactionColumnType.Amount,
|
||||
['Account2']: ImportTransactionColumnType.RelatedAccountName,
|
||||
['Account2 Currency']: ImportTransactionColumnType.RelatedAccountCurrency,
|
||||
['Account2 Amount']: ImportTransactionColumnType.RelatedAmount,
|
||||
['Geographic Location']: ImportTransactionColumnType.GeographicLocation,
|
||||
['Tags']: ImportTransactionColumnType.Tags,
|
||||
['Description']: ImportTransactionColumnType.Description
|
||||
},
|
||||
// Other common columns of transaction time
|
||||
{
|
||||
// en
|
||||
['Date']: ImportTransactionColumnType.TransactionTime,
|
||||
['Datetime']: ImportTransactionColumnType.TransactionTime,
|
||||
['Timestamp']: ImportTransactionColumnType.TransactionTime,
|
||||
// zh-Hans
|
||||
['日期']: ImportTransactionColumnType.TransactionTime,
|
||||
['时间']: ImportTransactionColumnType.TransactionTime,
|
||||
['交易日期']: ImportTransactionColumnType.TransactionTime,
|
||||
['交易时间']: ImportTransactionColumnType.TransactionTime,
|
||||
},
|
||||
// Other common columns of transaction timezone
|
||||
{
|
||||
|
||||
},
|
||||
// Other common columns of transaction type
|
||||
{
|
||||
// en
|
||||
['Transaction Type']: ImportTransactionColumnType.TransactionType,
|
||||
// zh-Hans
|
||||
['交易类型']: ImportTransactionColumnType.TransactionType,
|
||||
['类型']: ImportTransactionColumnType.TransactionType,
|
||||
['收/支']: ImportTransactionColumnType.TransactionType,
|
||||
},
|
||||
// Other common columns of category
|
||||
{
|
||||
// en
|
||||
['Category Name']: ImportTransactionColumnType.Category,
|
||||
// zh-Hans
|
||||
['交易分类']: ImportTransactionColumnType.Category,
|
||||
['类别']: ImportTransactionColumnType.Category,
|
||||
['分类']: ImportTransactionColumnType.Category,
|
||||
},
|
||||
// Other common columns of sub category
|
||||
{
|
||||
// zh-Hans
|
||||
['子类别']: ImportTransactionColumnType.SubCategory,
|
||||
['子分类']: ImportTransactionColumnType.SubCategory,
|
||||
['二级分类']: ImportTransactionColumnType.SubCategory,
|
||||
},
|
||||
// Other common columns of account name
|
||||
{
|
||||
// en
|
||||
['Account Name']: ImportTransactionColumnType.AccountName,
|
||||
['Source Name']: ImportTransactionColumnType.AccountName,
|
||||
// zh-Hans
|
||||
['账户']: ImportTransactionColumnType.AccountName,
|
||||
['账户1']: ImportTransactionColumnType.AccountName,
|
||||
},
|
||||
// Other common columns of account currency
|
||||
{
|
||||
// en
|
||||
['Currency']: ImportTransactionColumnType.AccountCurrency,
|
||||
['Currency Code']: ImportTransactionColumnType.AccountCurrency,
|
||||
// zh-Hans
|
||||
['账户币种']: ImportTransactionColumnType.AccountCurrency,
|
||||
['币种']: ImportTransactionColumnType.AccountCurrency,
|
||||
},
|
||||
// Other common columns of amount
|
||||
{
|
||||
// zh-Hans
|
||||
['金额']: ImportTransactionColumnType.Amount,
|
||||
},
|
||||
// Other common columns of related account name
|
||||
{
|
||||
// en
|
||||
['Destination Name']: ImportTransactionColumnType.RelatedAccountName,
|
||||
// zh-Hans
|
||||
['账户2']: ImportTransactionColumnType.RelatedAccountName,
|
||||
},
|
||||
// Other common columns of related account currency
|
||||
{
|
||||
// en
|
||||
['Foreign Currency']: ImportTransactionColumnType.RelatedAccountCurrency,
|
||||
['Foreign Currency Code']: ImportTransactionColumnType.RelatedAccountCurrency,
|
||||
},
|
||||
// Other common columns of related amount
|
||||
{
|
||||
// en
|
||||
['Foreign Amount']: ImportTransactionColumnType.RelatedAmount,
|
||||
},
|
||||
// Other common columns of geographic location
|
||||
{
|
||||
|
||||
},
|
||||
// Other common columns of tags
|
||||
{
|
||||
// zh-Hans
|
||||
['标签']: ImportTransactionColumnType.Tags,
|
||||
},
|
||||
// Other common columns of description
|
||||
{
|
||||
// en
|
||||
['Comment']: ImportTransactionColumnType.Description,
|
||||
['Note']: ImportTransactionColumnType.Description,
|
||||
['Memo']: ImportTransactionColumnType.Description,
|
||||
// zh-Hans
|
||||
['备注']: ImportTransactionColumnType.Description,
|
||||
}
|
||||
]);
|
||||
|
||||
export const KNOWN_TRANSACTION_TYPE_NAME_MAPPING: Record<string, TransactionType> = ((mappings: Record<string, TransactionType>[]) => {
|
||||
const result: Record<string, TransactionType> = {};
|
||||
|
||||
for (const mapping of mappings) {
|
||||
for (const [key, value] of entries(mapping)) {
|
||||
const normalizedKey = key.toLowerCase().replaceAll(' ', '').replaceAll('_', '').replaceAll('-', '');
|
||||
|
||||
if (result[normalizedKey]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
result[normalizedKey] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
})([
|
||||
// Transaction types of ezbookkeeping Data Export File
|
||||
{
|
||||
['Balance Modification']: TransactionType.ModifyBalance,
|
||||
['Income']: TransactionType.Income,
|
||||
['Expense']: TransactionType.Expense,
|
||||
['Transfer']: TransactionType.Transfer,
|
||||
},
|
||||
// Other common balance modification type
|
||||
{
|
||||
// en
|
||||
['Opening balance']: TransactionType.ModifyBalance,
|
||||
// zh-Hans
|
||||
['余额变更']: TransactionType.ModifyBalance,
|
||||
['负债变更']: TransactionType.ModifyBalance,
|
||||
},
|
||||
// Other common income type
|
||||
{
|
||||
// en
|
||||
['Deposit']: TransactionType.Income,
|
||||
// zh-Hans
|
||||
['收入']: TransactionType.Income,
|
||||
},
|
||||
// Other common expense type
|
||||
{
|
||||
// en
|
||||
['Withdrawal']: TransactionType.Expense,
|
||||
// zh-Hans
|
||||
['支出']: TransactionType.Expense,
|
||||
},
|
||||
// Other common transfer type
|
||||
{
|
||||
// zh-Hans
|
||||
['转账']: TransactionType.Transfer,
|
||||
['还款']: TransactionType.Transfer,
|
||||
['借入']: TransactionType.Transfer,
|
||||
['借出']: TransactionType.Transfer,
|
||||
['收债']: TransactionType.Transfer,
|
||||
['还债']: TransactionType.Transfer,
|
||||
['代付']: TransactionType.Transfer,
|
||||
['报销']: TransactionType.Transfer,
|
||||
['退款']: TransactionType.Transfer,
|
||||
}
|
||||
]);
|
||||
+11
-2
@@ -65,12 +65,17 @@ export function* values<K extends string | number | symbol, V>(obj: Record<K, V>
|
||||
}
|
||||
}
|
||||
|
||||
export interface NameValue {
|
||||
export interface GenericNameValue<T> {
|
||||
readonly name: string;
|
||||
readonly value: T;
|
||||
}
|
||||
|
||||
export interface NameValue extends GenericNameValue<string> {
|
||||
readonly name: string;
|
||||
readonly value: string;
|
||||
}
|
||||
|
||||
export interface NameNumeralValue {
|
||||
export interface NameNumeralValue extends GenericNameValue<number> {
|
||||
readonly name: string;
|
||||
readonly value: number;
|
||||
}
|
||||
@@ -85,6 +90,10 @@ export interface TypeAndName {
|
||||
readonly name: string;
|
||||
}
|
||||
|
||||
export interface TypeAndNameWithAlternativeName extends TypeAndName {
|
||||
readonly alternativeName?: string;
|
||||
}
|
||||
|
||||
export interface TypeAndDisplayName {
|
||||
readonly type: number;
|
||||
readonly displayName: string;
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
export interface BrowserCacheStatistics {
|
||||
readonly totalCacheSize: number;
|
||||
readonly codeCacheSize: number;
|
||||
readonly assetsCacheSize: number;
|
||||
readonly mapCacheSize: number;
|
||||
readonly othersCacheSize: number;
|
||||
}
|
||||
|
||||
export interface SWMapCacheConfig {
|
||||
enabled: boolean;
|
||||
patterns: string[];
|
||||
maxEntries: number;
|
||||
maxAgeMilliseconds: number;
|
||||
}
|
||||
+196
-117
@@ -345,116 +345,41 @@ export class MeridiemIndicator {
|
||||
}
|
||||
}
|
||||
|
||||
export class KnownDateTimeFormat {
|
||||
private static readonly allInstances: KnownDateTimeFormat[] = [];
|
||||
|
||||
public static readonly DefaultDateTime = new KnownDateTimeFormat('YYYY-MM-DD HH:mm:ss', /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[1-2][0-9]|3[0-1]) ([0-1][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]$/);
|
||||
public static readonly DefaultDateTimeWithTimezone = new KnownDateTimeFormat('YYYY-MM-DD HH:mm:ssZ', /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[1-2][0-9]|3[0-1]) ([0-1][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9](Z|[+-](0[0-9]|1[0-4]):[0-5][0-9])$/);
|
||||
public static readonly DefaultDateTimeWithoutSecond = new KnownDateTimeFormat('YYYY-MM-DD HH:mm', /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[1-2][0-9]|3[0-1]) ([0-1][0-9]|2[0-3]):[0-5][0-9]$/);
|
||||
public static readonly DefaultDate = new KnownDateTimeFormat('YYYY-MM-DD', /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[1-2][0-9]|3[0-1])$/);
|
||||
public static readonly RFC3339 = new KnownDateTimeFormat('YYYY-MM-DDTHH:mm:ssZ', /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[1-2][0-9]|3[0-1])T([0-1][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9](Z|[+-](0[0-9]|1[0-4]):[0-5][0-9])$/);
|
||||
public static readonly YYYYMMDDSlashWithTime = new KnownDateTimeFormat('YYYY/MM/DD HH:mm:ss', /^\d{4}\/(0[1-9]|1[0-2])\/(0[1-9]|[1-2][0-9]|3[0-1]) ([0-1][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]$/);
|
||||
public static readonly MMDDYYSlashWithTime = new KnownDateTimeFormat('MM/DD/YYYY HH:mm:ss', /^(0[1-9]|1[0-2])\/(0[1-9]|[1-2][0-9]|3[0-1])\/\d{4} ([0-1][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]$/);
|
||||
public static readonly DDMMYYSlashWithTime = new KnownDateTimeFormat('DD/MM/YYYY HH:mm:ss', /^(0[1-9]|[1-2][0-9]|3[0-1])\/(0[1-9]|1[0-2])\/\d{4} ([0-1][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]$/);
|
||||
public static readonly YYYYMMDDDotWithTime = new KnownDateTimeFormat('YYYY.MM.DD HH:mm:ss', /^\d{4}\.(0[1-9]|1[0-2])\.(0[1-9]|[1-2][0-9]|3[0-1]) ([0-1][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]$/);
|
||||
public static readonly MMDDYYDotWithTime = new KnownDateTimeFormat('MM.DD.YYYY HH:mm:ss', /^(0[1-9]|1[0-2])\.(0[1-9]|[1-2][0-9]|3[0-1])\.\d{4} ([0-1][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]$/);
|
||||
public static readonly DDMMYYDotWithTime = new KnownDateTimeFormat('DD.MM.YYYY HH:mm:ss', /^(0[1-9]|[1-2][0-9]|3[0-1])\.(0[1-9]|1[0-2])\.\d{4} ([0-1][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]$/);
|
||||
public static readonly YYYYMMDDSlash = new KnownDateTimeFormat('YYYY/MM/DD', /^\d{4}\/(0[1-9]|1[0-2])\/(0[1-9]|[1-2][0-9]|3[0-1])$/);
|
||||
public static readonly MMDDYYSlash = new KnownDateTimeFormat('MM/DD/YYYY', /^(0[1-9]|1[0-2])\/(0[1-9]|[1-2][0-9]|3[0-1])\/\d{4}$/);
|
||||
public static readonly DDMMYYSlash = new KnownDateTimeFormat('DD/MM/YYYY', /^(0[1-9]|[1-2][0-9]|3[0-1])\/(0[1-9]|1[0-2])\/\d{4}$/);
|
||||
public static readonly YYYYMMDDDot = new KnownDateTimeFormat('YYYY.MM.DD', /^\d{4}\.(0[1-9]|1[0-2])\.(0[1-9]|[1-2][0-9]|3[0-1])$/);
|
||||
public static readonly MMDDYYDot = new KnownDateTimeFormat('MM.DD.YYYY', /^(0[1-9]|1[0-2])\.(0[1-9]|[1-2][0-9]|3[0-1])\.\d{4}$/);
|
||||
public static readonly DDMMYYDot = new KnownDateTimeFormat('DD.MM.YYYY', /^(0[1-9]|[1-2][0-9]|3[0-1])\.(0[1-9]|1[0-2])\.\d{4}$/);
|
||||
public static readonly YYYYMMDD = new KnownDateTimeFormat('YYYYMMDD', /^\d{4}(0[1-9]|1[0-2])(0[1-9]|[1-2][0-9]|3[0-1])$/);
|
||||
|
||||
public readonly format: string;
|
||||
private readonly regex: RegExp;
|
||||
|
||||
private constructor(format: string, regex: RegExp) {
|
||||
this.format = format;
|
||||
this.regex = regex;
|
||||
|
||||
KnownDateTimeFormat.allInstances.push(this);
|
||||
}
|
||||
|
||||
public isValid(dateTime: string): boolean {
|
||||
return this.regex.test(dateTime);
|
||||
}
|
||||
|
||||
public static values(): KnownDateTimeFormat[] {
|
||||
return KnownDateTimeFormat.allInstances;
|
||||
}
|
||||
|
||||
public static detect(dateTime: string): KnownDateTimeFormat[] | undefined {
|
||||
const result: KnownDateTimeFormat[] = [];
|
||||
|
||||
for (const format of KnownDateTimeFormat.allInstances) {
|
||||
if (format.isValid(dateTime)) {
|
||||
result.push(format);
|
||||
}
|
||||
}
|
||||
|
||||
return result.length > 0 ? result : undefined;
|
||||
}
|
||||
|
||||
public static detectMulti(dateTimes: string[]): KnownDateTimeFormat[] | undefined {
|
||||
const detectedCounts: Record<string, number> = {};
|
||||
|
||||
for (const dateTime of dateTimes) {
|
||||
const detectedFormats = KnownDateTimeFormat.detect(dateTime);
|
||||
|
||||
if (detectedFormats) {
|
||||
for (const format of detectedFormats) {
|
||||
detectedCounts[format.format] = (detectedCounts[format.format] || 0) + 1;
|
||||
}
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const result: KnownDateTimeFormat[] = [];
|
||||
|
||||
for (const format of KnownDateTimeFormat.allInstances) {
|
||||
if (detectedCounts[format.format] === dateTimes.length) {
|
||||
result.push(format);
|
||||
}
|
||||
}
|
||||
|
||||
return result.length > 0 ? result : undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export const LANGUAGE_DEFAULT_DATE_TIME_FORMAT_VALUE: number = 0;
|
||||
|
||||
export enum DateFormatOrder {
|
||||
YMD = 1,
|
||||
MDY = 2,
|
||||
DMY = 3
|
||||
}
|
||||
|
||||
export interface DateFormat {
|
||||
readonly type: number;
|
||||
readonly key: string;
|
||||
readonly isMonthAfterYear: boolean;
|
||||
readonly typeName: string;
|
||||
readonly order: DateFormatOrder;
|
||||
}
|
||||
|
||||
type DateFormatTypeName = 'YYYYMMDD' | 'MMDDYYYY' | 'DDMMYYYY';
|
||||
type DateFormatTypeName = 'YearMonthDay' | 'MonthDayYear' | 'DayMonthYear';
|
||||
|
||||
export class LongDateFormat implements DateFormat {
|
||||
private static readonly allInstances: LongDateFormat[] = [];
|
||||
private static readonly allInstancesByType: Record<number, LongDateFormat> = {};
|
||||
private static readonly allInstancesByTypeName: Record<string, LongDateFormat> = {};
|
||||
|
||||
public static readonly YYYYMMDD = new LongDateFormat(1, 'YYYYMMDD', 'yyyy_mm_dd', true);
|
||||
public static readonly MMDDYYYY = new LongDateFormat(2, 'MMDDYYYY', 'mm_dd_yyyy', false);
|
||||
public static readonly DDMMYYYY = new LongDateFormat(3, 'DDMMYYYY', 'dd_mm_yyyy', false);
|
||||
public static readonly YearMonthDay = new LongDateFormat(1, 'YearMonthDay', DateFormatOrder.YMD);
|
||||
public static readonly MonthDayYear = new LongDateFormat(2, 'MonthDayYear', DateFormatOrder.MDY);
|
||||
public static readonly DayMonthYear = new LongDateFormat(3, 'DayMonthYear', DateFormatOrder.DMY);
|
||||
|
||||
public static readonly Default = LongDateFormat.YYYYMMDD;
|
||||
public static readonly Default = LongDateFormat.YearMonthDay;
|
||||
|
||||
public readonly type: number;
|
||||
public readonly typeName: string;
|
||||
public readonly key: string;
|
||||
public readonly isMonthAfterYear: boolean;
|
||||
public readonly order: DateFormatOrder;
|
||||
|
||||
private constructor(type: number, typeName: DateFormatTypeName, key: string, isMonthAfterYear: boolean) {
|
||||
private constructor(type: number, typeName: DateFormatTypeName, order: DateFormatOrder) {
|
||||
this.type = type;
|
||||
this.typeName = typeName;
|
||||
this.key = key;
|
||||
this.isMonthAfterYear = isMonthAfterYear;
|
||||
this.order = order;
|
||||
|
||||
LongDateFormat.allInstances.push(this);
|
||||
LongDateFormat.allInstancesByType[type] = this;
|
||||
@@ -479,22 +404,20 @@ export class ShortDateFormat implements DateFormat {
|
||||
private static readonly allInstancesByType: Record<number, ShortDateFormat> = {};
|
||||
private static readonly allInstancesByTypeName: Record<string, ShortDateFormat> = {};
|
||||
|
||||
public static readonly YYYYMMDD = new ShortDateFormat(1, 'YYYYMMDD', 'yyyy_mm_dd', true);
|
||||
public static readonly MMDDYYYY = new ShortDateFormat(2, 'MMDDYYYY', 'mm_dd_yyyy', false);
|
||||
public static readonly DDMMYYYY = new ShortDateFormat(3, 'DDMMYYYY', 'dd_mm_yyyy', false);
|
||||
public static readonly YearMonthDay = new ShortDateFormat(1, 'YearMonthDay', DateFormatOrder.YMD);
|
||||
public static readonly MonthDayYear = new ShortDateFormat(2, 'MonthDayYear', DateFormatOrder.MDY);
|
||||
public static readonly DayMonthYear = new ShortDateFormat(3, 'DayMonthYear', DateFormatOrder.DMY);
|
||||
|
||||
public static readonly Default = ShortDateFormat.YYYYMMDD;
|
||||
public static readonly Default = ShortDateFormat.YearMonthDay;
|
||||
|
||||
public readonly type: number;
|
||||
public readonly typeName: string;
|
||||
public readonly key: string;
|
||||
public readonly isMonthAfterYear: boolean;
|
||||
public readonly order: DateFormatOrder;
|
||||
|
||||
private constructor(type: number, typeName: DateFormatTypeName, key: string, isMonthAfterYear: boolean) {
|
||||
private constructor(type: number, typeName: DateFormatTypeName, order: DateFormatOrder) {
|
||||
this.type = type;
|
||||
this.typeName = typeName;
|
||||
this.key = key;
|
||||
this.isMonthAfterYear = isMonthAfterYear;
|
||||
this.order = order;
|
||||
|
||||
ShortDateFormat.allInstances.push(this);
|
||||
ShortDateFormat.allInstancesByType[type] = this;
|
||||
@@ -516,34 +439,32 @@ export class ShortDateFormat implements DateFormat {
|
||||
|
||||
export interface TimeFormat {
|
||||
readonly type: number;
|
||||
readonly key: string;
|
||||
readonly typeName: string;
|
||||
readonly is24HourFormat: boolean;
|
||||
readonly isMeridiemIndicatorFirst: boolean | null;
|
||||
}
|
||||
|
||||
export type LongTimeFormatTypeName = 'HHMMSS' | 'AHHMMSS' | 'HHMMSSA';
|
||||
export type LongTimeFormatTypeName = 'HourMinuteSecond' | 'MeridiemIndicatorHourMinuteSecond' | 'HourMinuteSecondMeridiemIndicator';
|
||||
|
||||
export class LongTimeFormat implements TimeFormat {
|
||||
private static readonly allInstances: LongTimeFormat[] = [];
|
||||
private static readonly allInstancesByType: Record<number, LongTimeFormat> = {};
|
||||
private static readonly allInstancesByTypeName: Record<string, LongTimeFormat> = {};
|
||||
|
||||
public static readonly HHMMSS = new LongTimeFormat(1, 'HHMMSS', 'hh_mm_ss', true, null);
|
||||
public static readonly AHHMMSS = new LongTimeFormat(2, 'AHHMMSS', 'a_hh_mm_ss', false, true);
|
||||
public static readonly HHMMSSA = new LongTimeFormat(3, 'HHMMSSA', 'hh_mm_ss_a', false, false);
|
||||
public static readonly HourMinuteSecond = new LongTimeFormat(1, 'HourMinuteSecond', true, null);
|
||||
public static readonly MeridiemIndicatorHourMinuteSecond = new LongTimeFormat(2, 'MeridiemIndicatorHourMinuteSecond', false, true);
|
||||
public static readonly HourMinuteSecondMeridiemIndicator = new LongTimeFormat(3, 'HourMinuteSecondMeridiemIndicator', false, false);
|
||||
|
||||
public static readonly Default = LongTimeFormat.HHMMSS;
|
||||
public static readonly Default = LongTimeFormat.HourMinuteSecond;
|
||||
|
||||
public readonly type: number;
|
||||
public readonly typeName: string;
|
||||
public readonly key: string;
|
||||
public readonly is24HourFormat: boolean;
|
||||
public readonly isMeridiemIndicatorFirst: boolean | null;
|
||||
|
||||
private constructor(type: number, typeName: LongTimeFormatTypeName, key: string, is24HourFormat: boolean, isMeridiemIndicatorFirst: boolean | null) {
|
||||
private constructor(type: number, typeName: LongTimeFormatTypeName, is24HourFormat: boolean, isMeridiemIndicatorFirst: boolean | null) {
|
||||
this.type = type;
|
||||
this.typeName = typeName;
|
||||
this.key = key;
|
||||
this.is24HourFormat = is24HourFormat;
|
||||
this.isMeridiemIndicatorFirst = isMeridiemIndicatorFirst;
|
||||
|
||||
@@ -565,29 +486,27 @@ export class LongTimeFormat implements TimeFormat {
|
||||
}
|
||||
}
|
||||
|
||||
export type ShortTimeFormatTypeName = 'HHMM' | 'AHHMM' | 'HHMMA';
|
||||
export type ShortTimeFormatTypeName = 'HourMinute' | 'MeridiemIndicatorHourMinute' | 'HourMinuteMeridiemIndicator';
|
||||
|
||||
export class ShortTimeFormat implements TimeFormat {
|
||||
private static readonly allInstances: ShortTimeFormat[] = [];
|
||||
private static readonly allInstancesByType: Record<number, ShortTimeFormat> = {};
|
||||
private static readonly allInstancesByTypeName: Record<string, ShortTimeFormat> = {};
|
||||
|
||||
public static readonly HHMM = new ShortTimeFormat(1, 'HHMM', 'hh_mm', true, null);
|
||||
public static readonly AHHMM = new ShortTimeFormat(2, 'AHHMM', 'a_hh_mm', false, true);
|
||||
public static readonly HHMMA = new ShortTimeFormat(3, 'HHMMA', 'hh_mm_a', false, false);
|
||||
public static readonly HourMinute = new ShortTimeFormat(1, 'HourMinute', true, null);
|
||||
public static readonly MeridiemIndicatorHourMinute = new ShortTimeFormat(2, 'MeridiemIndicatorHourMinute', false, true);
|
||||
public static readonly HourMinuteMeridiemIndicator = new ShortTimeFormat(3, 'HourMinuteMeridiemIndicator', false, false);
|
||||
|
||||
public static readonly Default = ShortTimeFormat.HHMM;
|
||||
public static readonly Default = ShortTimeFormat.HourMinute;
|
||||
|
||||
public readonly type: number;
|
||||
public readonly typeName: string;
|
||||
public readonly key: string;
|
||||
public readonly is24HourFormat: boolean;
|
||||
public readonly isMeridiemIndicatorFirst: boolean | null;
|
||||
|
||||
private constructor(type: number, typeName: ShortTimeFormatTypeName, key: string, is24HourFormat: boolean, isMeridiemIndicatorFirst: boolean | null) {
|
||||
private constructor(type: number, typeName: ShortTimeFormatTypeName, is24HourFormat: boolean, isMeridiemIndicatorFirst: boolean | null) {
|
||||
this.type = type;
|
||||
this.typeName = typeName;
|
||||
this.key = key;
|
||||
this.is24HourFormat = is24HourFormat;
|
||||
this.isMeridiemIndicatorFirst = isMeridiemIndicatorFirst;
|
||||
|
||||
@@ -609,6 +528,166 @@ export class ShortTimeFormat implements TimeFormat {
|
||||
}
|
||||
}
|
||||
|
||||
export class KnownDateTimeFormat {
|
||||
private static readonly allInstances: KnownDateTimeFormat[] = [];
|
||||
private static readonly allYMDInstances: KnownDateTimeFormat[] = [];
|
||||
private static readonly allMDYInstances: KnownDateTimeFormat[] = [];
|
||||
private static readonly allDMYInstances: KnownDateTimeFormat[] = [];
|
||||
|
||||
public static readonly DefaultDateTime = new KnownDateTimeFormat('YYYY-MM-DD HH:mm:ss', DateFormatOrder.YMD, /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[1-2][0-9]|3[0-1]) ([0-1][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]$/);
|
||||
public static readonly DefaultDateTimeWithTimezone = new KnownDateTimeFormat('YYYY-MM-DD HH:mm:ssZ', DateFormatOrder.YMD, /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[1-2][0-9]|3[0-1]) ([0-1][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9](Z|[+-](0[0-9]|1[0-4]):[0-5][0-9])$/);
|
||||
public static readonly DefaultDateTimeWithoutSecond = new KnownDateTimeFormat('YYYY-MM-DD HH:mm', DateFormatOrder.YMD, /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[1-2][0-9]|3[0-1]) ([0-1][0-9]|2[0-3]):[0-5][0-9]$/);
|
||||
public static readonly DefaultDate = new KnownDateTimeFormat('YYYY-MM-DD', DateFormatOrder.YMD, /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[1-2][0-9]|3[0-1])$/);
|
||||
|
||||
public static readonly RFC3339 = new KnownDateTimeFormat('YYYY-MM-DDTHH:mm:ssZ', DateFormatOrder.YMD, /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[1-2][0-9]|3[0-1])T([0-1][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9](Z|[+-](0[0-9]|1[0-4]):[0-5][0-9])$/);
|
||||
|
||||
public static readonly YYYYMMDDSlashWithTime = new KnownDateTimeFormat('YYYY/MM/DD HH:mm:ss', DateFormatOrder.YMD, /^\d{4}\/(0[1-9]|1[0-2])\/(0[1-9]|[1-2][0-9]|3[0-1]) ([0-1][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]$/);
|
||||
public static readonly MMDDYYYYSlashWithTime = new KnownDateTimeFormat('MM/DD/YYYY HH:mm:ss', DateFormatOrder.MDY, /^(0[1-9]|1[0-2])\/(0[1-9]|[1-2][0-9]|3[0-1])\/\d{4} ([0-1][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]$/);
|
||||
public static readonly DDMMYYYYSlashWithTime = new KnownDateTimeFormat('DD/MM/YYYY HH:mm:ss', DateFormatOrder.DMY, /^(0[1-9]|[1-2][0-9]|3[0-1])\/(0[1-9]|1[0-2])\/\d{4} ([0-1][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]$/);
|
||||
|
||||
public static readonly YYYYMMDDDotWithTime = new KnownDateTimeFormat('YYYY.MM.DD HH:mm:ss', DateFormatOrder.YMD, /^\d{4}\.(0[1-9]|1[0-2])\.(0[1-9]|[1-2][0-9]|3[0-1]) ([0-1][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]$/);
|
||||
public static readonly MMDDYYYYDotWithTime = new KnownDateTimeFormat('MM.DD.YYYY HH:mm:ss', DateFormatOrder.MDY, /^(0[1-9]|1[0-2])\.(0[1-9]|[1-2][0-9]|3[0-1])\.\d{4} ([0-1][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]$/);
|
||||
public static readonly DDMMYYYYDotWithTime = new KnownDateTimeFormat('DD.MM.YYYY HH:mm:ss', DateFormatOrder.DMY, /^(0[1-9]|[1-2][0-9]|3[0-1])\.(0[1-9]|1[0-2])\.\d{4} ([0-1][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]$/);
|
||||
|
||||
public static readonly MMDDYYYYDash = new KnownDateTimeFormat('MM-DD-YYYY', DateFormatOrder.MDY, /^(0[1-9]|1[0-2])-(0[1-9]|[1-2][0-9]|3[0-1])-\d{4}$/);
|
||||
public static readonly DDMMYYYYDash = new KnownDateTimeFormat('DD-MM-YYYY', DateFormatOrder.DMY, /^(0[1-9]|[1-2][0-9]|3[0-1])-(0[1-9]|1[0-2])-\d{4}$/);
|
||||
|
||||
public static readonly YYYYMMDDSlash = new KnownDateTimeFormat('YYYY/MM/DD', DateFormatOrder.YMD, /^\d{4}\/(0[1-9]|1[0-2])\/(0[1-9]|[1-2][0-9]|3[0-1])$/);
|
||||
public static readonly MMDDYYYYSlash = new KnownDateTimeFormat('MM/DD/YYYY', DateFormatOrder.MDY, /^(0[1-9]|1[0-2])\/(0[1-9]|[1-2][0-9]|3[0-1])\/\d{4}$/);
|
||||
public static readonly DDMMYYYYSlash = new KnownDateTimeFormat('DD/MM/YYYY', DateFormatOrder.DMY, /^(0[1-9]|[1-2][0-9]|3[0-1])\/(0[1-9]|1[0-2])\/\d{4}$/);
|
||||
|
||||
public static readonly YYYYMDSlash = new KnownDateTimeFormat('YYYY/M/D', DateFormatOrder.YMD, /^\d{4}\/([1-9]|1[0-2])\/([1-9]|[1-2][0-9]|3[0-1])$/);
|
||||
public static readonly MDYYYYSlash = new KnownDateTimeFormat('M/D/YYYY', DateFormatOrder.MDY, /^([1-9]|1[0-2])\/([1-9]|[1-2][0-9]|3[0-1])\/\d{4}$/);
|
||||
public static readonly DMYYYYSlash = new KnownDateTimeFormat('D/M/YYYY', DateFormatOrder.DMY, /^([1-9]|[1-2][0-9]|3[0-1])\/([1-9]|1[0-2])\/\d{4}$/);
|
||||
|
||||
public static readonly YYYYMMDDDot = new KnownDateTimeFormat('YYYY.MM.DD', DateFormatOrder.YMD, /^\d{4}\.(0[1-9]|1[0-2])\.(0[1-9]|[1-2][0-9]|3[0-1])$/);
|
||||
public static readonly MMDDYYYYDot = new KnownDateTimeFormat('MM.DD.YYYY', DateFormatOrder.MDY, /^(0[1-9]|1[0-2])\.(0[1-9]|[1-2][0-9]|3[0-1])\.\d{4}$/);
|
||||
public static readonly DDMMYYYYDot = new KnownDateTimeFormat('DD.MM.YYYY', DateFormatOrder.DMY, /^(0[1-9]|[1-2][0-9]|3[0-1])\.(0[1-9]|1[0-2])\.\d{4}$/);
|
||||
|
||||
public static readonly YYYYMDDot = new KnownDateTimeFormat('YYYY.M.D', DateFormatOrder.YMD, /^\d{4}\.([1-9]|1[0-2])\.([1-9]|[1-2][0-9]|3[0-1])$/);
|
||||
public static readonly MDYYYYDot = new KnownDateTimeFormat('M.D.YYYY', DateFormatOrder.MDY, /^([1-9]|1[0-2])\.([1-9]|[1-2][0-9]|3[0-1])\.\d{4}$/);
|
||||
public static readonly DMYYYYDot = new KnownDateTimeFormat('D.M.YYYY', DateFormatOrder.DMY, /^([1-9]|[1-2][0-9]|3[0-1])\.([1-9]|1[0-2])\.\d{4}$/);
|
||||
|
||||
public static readonly YYYYMMDD = new KnownDateTimeFormat('YYYYMMDD', DateFormatOrder.YMD, /^\d{4}(0[1-9]|1[0-2])(0[1-9]|[1-2][0-9]|3[0-1])$/);
|
||||
|
||||
public static readonly MMDDYYDash = new KnownDateTimeFormat('MM-DD-YY', DateFormatOrder.MDY, /^(0[1-9]|1[0-2])-(0[1-9]|[1-2][0-9]|3[0-1])-\d{2}$/);
|
||||
public static readonly MMDDYYSlash = new KnownDateTimeFormat('MM/DD/YY', DateFormatOrder.MDY, /^(0[1-9]|1[0-2])\/(0[1-9]|[1-2][0-9]|3[0-1])\/\d{2}$/);
|
||||
|
||||
public readonly format: string;
|
||||
public readonly type: DateFormatOrder;
|
||||
private readonly regex: RegExp;
|
||||
|
||||
private constructor(format: string, type: DateFormatOrder, regex: RegExp) {
|
||||
this.format = format;
|
||||
this.type = type;
|
||||
this.regex = regex;
|
||||
|
||||
if (type === DateFormatOrder.YMD) {
|
||||
KnownDateTimeFormat.allYMDInstances.push(this);
|
||||
} else if (type === DateFormatOrder.MDY) {
|
||||
KnownDateTimeFormat.allMDYInstances.push(this);
|
||||
} else if (type === DateFormatOrder.DMY) {
|
||||
KnownDateTimeFormat.allDMYInstances.push(this);
|
||||
}
|
||||
|
||||
KnownDateTimeFormat.allInstances.push(this);
|
||||
}
|
||||
|
||||
public isValid(dateTime: string): boolean {
|
||||
return this.regex.test(dateTime);
|
||||
}
|
||||
|
||||
public static values(): KnownDateTimeFormat[] {
|
||||
return KnownDateTimeFormat.allInstances;
|
||||
}
|
||||
|
||||
public static detect(dateTime: string, longDateTimeFormatOrder: DateFormatOrder, shortDateTimeFormatOrder: DateFormatOrder): KnownDateTimeFormat[] | undefined {
|
||||
const allFormats: KnownDateTimeFormat[] = KnownDateTimeFormat.getAllFormatsByOrder(longDateTimeFormatOrder, shortDateTimeFormatOrder);
|
||||
return KnownDateTimeFormat.detectSingle(dateTime, allFormats);
|
||||
}
|
||||
|
||||
public static detectMulti(dateTimes: string[], longDateTimeFormatOrder: DateFormatOrder, shortDateTimeFormatOrder: DateFormatOrder): KnownDateTimeFormat[] | undefined {
|
||||
const detectedCounts: Record<string, number> = {};
|
||||
const allFormats: KnownDateTimeFormat[] = KnownDateTimeFormat.getAllFormatsByOrder(longDateTimeFormatOrder, shortDateTimeFormatOrder);
|
||||
|
||||
for (const dateTime of dateTimes) {
|
||||
const detectedFormats = KnownDateTimeFormat.detectSingle(dateTime, allFormats);
|
||||
|
||||
if (detectedFormats) {
|
||||
for (const format of detectedFormats) {
|
||||
detectedCounts[format.format] = (detectedCounts[format.format] || 0) + 1;
|
||||
}
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const result: KnownDateTimeFormat[] = [];
|
||||
|
||||
for (const format of KnownDateTimeFormat.allInstances) {
|
||||
if (detectedCounts[format.format] === dateTimes.length) {
|
||||
result.push(format);
|
||||
}
|
||||
}
|
||||
|
||||
return result.length > 0 ? result : undefined;
|
||||
}
|
||||
|
||||
private static detectSingle(dateTime: string, allFormats: KnownDateTimeFormat[]): KnownDateTimeFormat[] | undefined {
|
||||
const result: KnownDateTimeFormat[] = [];
|
||||
|
||||
for (const format of allFormats) {
|
||||
if (format.isValid(dateTime)) {
|
||||
result.push(format);
|
||||
}
|
||||
}
|
||||
|
||||
return result.length > 0 ? result : undefined;
|
||||
}
|
||||
|
||||
private static getAllFormatsByOrder(longDateTimeFormatOrder: DateFormatOrder, shortDateTimeFormatOrder: DateFormatOrder): KnownDateTimeFormat[] {
|
||||
if (longDateTimeFormatOrder === DateFormatOrder.YMD && (shortDateTimeFormatOrder === DateFormatOrder.YMD || shortDateTimeFormatOrder === DateFormatOrder.MDY)) {
|
||||
return [
|
||||
...KnownDateTimeFormat.allYMDInstances,
|
||||
...KnownDateTimeFormat.allMDYInstances,
|
||||
...KnownDateTimeFormat.allDMYInstances
|
||||
];
|
||||
} else if (longDateTimeFormatOrder === DateFormatOrder.YMD && shortDateTimeFormatOrder === DateFormatOrder.DMY) {
|
||||
return [
|
||||
...KnownDateTimeFormat.allYMDInstances,
|
||||
...KnownDateTimeFormat.allDMYInstances,
|
||||
...KnownDateTimeFormat.allMDYInstances
|
||||
];
|
||||
} else if (longDateTimeFormatOrder === DateFormatOrder.MDY && (shortDateTimeFormatOrder === DateFormatOrder.MDY || shortDateTimeFormatOrder === DateFormatOrder.YMD)) {
|
||||
return [
|
||||
...KnownDateTimeFormat.allMDYInstances,
|
||||
...KnownDateTimeFormat.allYMDInstances,
|
||||
...KnownDateTimeFormat.allDMYInstances
|
||||
];
|
||||
} else if (longDateTimeFormatOrder === DateFormatOrder.MDY && shortDateTimeFormatOrder === DateFormatOrder.DMY) {
|
||||
return [
|
||||
...KnownDateTimeFormat.allMDYInstances,
|
||||
...KnownDateTimeFormat.allDMYInstances,
|
||||
...KnownDateTimeFormat.allYMDInstances
|
||||
];
|
||||
} else if (longDateTimeFormatOrder === DateFormatOrder.DMY && (shortDateTimeFormatOrder === DateFormatOrder.DMY || shortDateTimeFormatOrder === DateFormatOrder.YMD)) {
|
||||
return [
|
||||
...KnownDateTimeFormat.allDMYInstances,
|
||||
...KnownDateTimeFormat.allYMDInstances,
|
||||
...KnownDateTimeFormat.allMDYInstances
|
||||
];
|
||||
} else if (longDateTimeFormatOrder === DateFormatOrder.DMY && shortDateTimeFormatOrder === DateFormatOrder.MDY) {
|
||||
return [
|
||||
...KnownDateTimeFormat.allDMYInstances,
|
||||
...KnownDateTimeFormat.allMDYInstances,
|
||||
...KnownDateTimeFormat.allYMDInstances
|
||||
];
|
||||
} else {
|
||||
return KnownDateTimeFormat.allInstances;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export enum DateRangeScene {
|
||||
Normal = 0,
|
||||
TrendAnalysis = 1,
|
||||
|
||||
+47
-11
@@ -4,17 +4,27 @@ import { DateRange } from '@/core/datetime.ts';
|
||||
export enum TransactionExplorerConditionRelation {
|
||||
First = 'first',
|
||||
And = 'and',
|
||||
Or = 'or'
|
||||
Or = 'or',
|
||||
AndSub = 'and(',
|
||||
OrSub = 'or(',
|
||||
SubEnd = ')'
|
||||
}
|
||||
|
||||
export type TransactionExplorerSubConditionStartRelation = '(';
|
||||
export const TransactionExplorerSubConditionStartRelationPlaceholder: TransactionExplorerSubConditionStartRelation = '(';
|
||||
|
||||
export const TransactionExplorerConditionRelationPriority: Record<TransactionExplorerConditionRelation, number> = {
|
||||
[TransactionExplorerConditionRelation.First]: 0,
|
||||
[TransactionExplorerConditionRelation.Or]: 1,
|
||||
[TransactionExplorerConditionRelation.And]: 2
|
||||
[TransactionExplorerConditionRelation.And]: 2,
|
||||
[TransactionExplorerConditionRelation.AndSub]: 0,
|
||||
[TransactionExplorerConditionRelation.OrSub]: 0,
|
||||
[TransactionExplorerConditionRelation.SubEnd]: 0
|
||||
};
|
||||
|
||||
|
||||
export enum TransactionExplorerConditionFieldType {
|
||||
Undefined = 'undefined',
|
||||
TransactionType = 'transactionType',
|
||||
TransactionCategory = 'transactionCategory',
|
||||
SourceAccount = 'sourceAccount',
|
||||
@@ -81,7 +91,11 @@ export enum TransactionExplorerConditionOperatorType {
|
||||
StartsWith = 'startsWith',
|
||||
NotStartsWith = 'notStartsWith',
|
||||
EndsWith = 'endsWith',
|
||||
NotEndsWith = 'notEndsWith'
|
||||
NotEndsWith = 'notEndsWith',
|
||||
LatitudeBetween = 'latitudeBetween',
|
||||
LatitudeNotBetween = 'latitudeNotBetween',
|
||||
LongitudeBetween = 'longitudeBetween',
|
||||
LongitudeNotBetween = 'longitudeNotBetween'
|
||||
}
|
||||
|
||||
export class TransactionExplorerConditionOperator implements NameValue {
|
||||
@@ -107,6 +121,10 @@ export class TransactionExplorerConditionOperator implements NameValue {
|
||||
public static readonly NotStartsWith = new TransactionExplorerConditionOperator('Not starts with', TransactionExplorerConditionOperatorType.NotStartsWith);
|
||||
public static readonly EndsWith = new TransactionExplorerConditionOperator('Ends with', TransactionExplorerConditionOperatorType.EndsWith);
|
||||
public static readonly NotEndsWith = new TransactionExplorerConditionOperator('Not ends with', TransactionExplorerConditionOperatorType.NotEndsWith);
|
||||
public static readonly LatitudeBetween = new TransactionExplorerConditionOperator('Latitude between', TransactionExplorerConditionOperatorType.LatitudeBetween);
|
||||
public static readonly LatitudeNotBetween = new TransactionExplorerConditionOperator('Latitude not between', TransactionExplorerConditionOperatorType.LatitudeNotBetween);
|
||||
public static readonly LongitudeBetween = new TransactionExplorerConditionOperator('Longitude between', TransactionExplorerConditionOperatorType.LongitudeBetween);
|
||||
public static readonly LongitudeNotBetween = new TransactionExplorerConditionOperator('Longitude not between', TransactionExplorerConditionOperatorType.LongitudeNotBetween);
|
||||
|
||||
public readonly name: string;
|
||||
public readonly value: TransactionExplorerConditionOperatorType;
|
||||
@@ -191,6 +209,8 @@ export enum TransactionExplorerDataDimensionType {
|
||||
DateTimeByDayOfMonth = 'dateTimeByDayOfMonth',
|
||||
DateTimeByMonthOfYear = 'dateTimeByMonthOfYear',
|
||||
DateTimeByQuarterOfYear = 'dateTimeByQuarterOfYear',
|
||||
DateTimeByHourOfDay = 'dateTimeByHourOfDay',
|
||||
TimezoneOffset = 'timezoneOffset',
|
||||
TransactionType = 'transactionType',
|
||||
SourceAccount = 'sourceAccount',
|
||||
SourceAccountCategory = 'sourceAccountCategory',
|
||||
@@ -220,6 +240,8 @@ export class TransactionExplorerDataDimension implements NameValue {
|
||||
public static readonly DateTimeByDayOfMonth = new TransactionExplorerDataDimension('Transaction Day of Month', TransactionExplorerDataDimensionType.DateTimeByDayOfMonth);
|
||||
public static readonly DateTimeByMonthOfYear = new TransactionExplorerDataDimension('Transaction Month of Year', TransactionExplorerDataDimensionType.DateTimeByMonthOfYear);
|
||||
public static readonly DateTimeByQuarterOfYear = new TransactionExplorerDataDimension('Transaction Quarter of Year', TransactionExplorerDataDimensionType.DateTimeByQuarterOfYear);
|
||||
public static readonly DateTimeByHourOfDay = new TransactionExplorerDataDimension('Transaction Hour of Day', TransactionExplorerDataDimensionType.DateTimeByHourOfDay);
|
||||
public static readonly TimezoneOffset = new TransactionExplorerDataDimension('Transaction Timezone', TransactionExplorerDataDimensionType.TimezoneOffset);
|
||||
public static readonly TransactionType = new TransactionExplorerDataDimension('Transaction Type', TransactionExplorerDataDimensionType.TransactionType);
|
||||
public static readonly SourceAccount = new TransactionExplorerDataDimension('Source Account', TransactionExplorerDataDimensionType.SourceAccount);
|
||||
public static readonly SourceAccountCategory = new TransactionExplorerDataDimension('Source Account Category', TransactionExplorerDataDimensionType.SourceAccountCategory);
|
||||
@@ -260,31 +282,45 @@ export enum TransactionExplorerValueMetricType {
|
||||
SourceAmountSum = 'sourceAmountSum',
|
||||
SourceAmountAverage = 'sourceAmountAverage',
|
||||
SourceAmountMedian = 'sourceAmountMedian',
|
||||
SourceAmount90thPercentile = 'source90thPercentileAmount',
|
||||
SourceAmountMinimum = 'sourceAmountMinimum',
|
||||
SourceAmountMaximum = 'sourceAmountMaximum'
|
||||
SourceAmountMaximum = 'sourceAmountMaximum',
|
||||
SourceAmountRange = 'sourceAmountRange',
|
||||
SourceAmountInterquartileRange = 'sourceAmountInterquartileRange',
|
||||
SourceAmountVariance = 'sourceAmountVariance',
|
||||
SourceAmountStandardDeviation = 'sourceAmountStandardDeviation',
|
||||
SourceAmountCoefficientOfVariation = 'sourceAmountCoefficientOfVariation'
|
||||
}
|
||||
|
||||
export class TransactionExplorerValueMetric implements NameValue {
|
||||
private static readonly allInstances: TransactionExplorerValueMetric[] = [];
|
||||
private static readonly allInstancesByValue: Record<string, TransactionExplorerValueMetric> = {};
|
||||
|
||||
public static readonly TransactionCount = new TransactionExplorerValueMetric('Transaction Count', TransactionExplorerValueMetricType.TransactionCount, false);
|
||||
public static readonly SourceAmountSum = new TransactionExplorerValueMetric('Total Amount', TransactionExplorerValueMetricType.SourceAmountSum, true);
|
||||
public static readonly SourceAmountAverage = new TransactionExplorerValueMetric('Average Amount', TransactionExplorerValueMetricType.SourceAmountAverage, true);
|
||||
public static readonly SourceAmountMedian = new TransactionExplorerValueMetric('Median Amount', TransactionExplorerValueMetricType.SourceAmountMedian, true);
|
||||
public static readonly SourceAmountMinimum = new TransactionExplorerValueMetric('Minimum Amount', TransactionExplorerValueMetricType.SourceAmountMinimum, true);
|
||||
public static readonly SourceAmountMaximum = new TransactionExplorerValueMetric('Maximum Amount', TransactionExplorerValueMetricType.SourceAmountMaximum, true);
|
||||
public static readonly TransactionCount = new TransactionExplorerValueMetric('Transaction Count', TransactionExplorerValueMetricType.TransactionCount, false, true);
|
||||
public static readonly SourceAmountSum = new TransactionExplorerValueMetric('Total Amount', TransactionExplorerValueMetricType.SourceAmountSum, true, true);
|
||||
public static readonly SourceAmountAverage = new TransactionExplorerValueMetric('Average Amount', TransactionExplorerValueMetricType.SourceAmountAverage, true, true);
|
||||
public static readonly SourceAmountMedian = new TransactionExplorerValueMetric('Median Amount', TransactionExplorerValueMetricType.SourceAmountMedian, true, true);
|
||||
public static readonly SourceAmount90thPercentile = new TransactionExplorerValueMetric('90th Percentile Amount', TransactionExplorerValueMetricType.SourceAmount90thPercentile, true, true);
|
||||
public static readonly SourceAmountMinimum = new TransactionExplorerValueMetric('Minimum Amount', TransactionExplorerValueMetricType.SourceAmountMinimum, true, true);
|
||||
public static readonly SourceAmountMaximum = new TransactionExplorerValueMetric('Maximum Amount', TransactionExplorerValueMetricType.SourceAmountMaximum, true, true);
|
||||
public static readonly SourceAmountRange = new TransactionExplorerValueMetric('Range (Max - Min)', TransactionExplorerValueMetricType.SourceAmountRange, true, true);
|
||||
public static readonly SourceAmountInterquartileRange = new TransactionExplorerValueMetric('Interquartile Range (Q3 - Q1)', TransactionExplorerValueMetricType.SourceAmountInterquartileRange, true, true);
|
||||
public static readonly SourceAmountVariance = new TransactionExplorerValueMetric('Variance', TransactionExplorerValueMetricType.SourceAmountVariance, false, false);
|
||||
public static readonly SourceAmountStandardDeviation = new TransactionExplorerValueMetric('Standard Deviation', TransactionExplorerValueMetricType.SourceAmountStandardDeviation, false, false);
|
||||
public static readonly SourceAmountCoefficientOfVariation = new TransactionExplorerValueMetric('Coefficient of Variation', TransactionExplorerValueMetricType.SourceAmountCoefficientOfVariation, false, false);
|
||||
|
||||
public static readonly Default = TransactionExplorerValueMetric.SourceAmountSum;
|
||||
|
||||
public readonly name: string;
|
||||
public readonly value: TransactionExplorerValueMetricType;
|
||||
public readonly isAmount: boolean;
|
||||
public readonly supportSum: boolean;
|
||||
|
||||
private constructor(name: string, value: TransactionExplorerValueMetricType, isAmount: boolean) {
|
||||
private constructor(name: string, value: TransactionExplorerValueMetricType, isAmount: boolean, supportSum: boolean) {
|
||||
this.name = name;
|
||||
this.value = value;
|
||||
this.isAmount = isAmount;
|
||||
this.supportSum = supportSum;
|
||||
|
||||
TransactionExplorerValueMetric.allInstances.push(this);
|
||||
TransactionExplorerValueMetric.allInstancesByValue[value] = this;
|
||||
|
||||
+3
-1
@@ -4,8 +4,10 @@ export class KnownFileType {
|
||||
public static readonly JSON = new KnownFileType('json', 'application/json');
|
||||
public static readonly CSV = new KnownFileType('csv', 'text/csv');
|
||||
public static readonly TSV = new KnownFileType('tsv', 'text/tab-separated-values');
|
||||
public static readonly TXT = new KnownFileType('txt', 'text/text');
|
||||
public static readonly SSV = new KnownFileType('txt', 'text/plain');
|
||||
public static readonly TXT = new KnownFileType('txt', 'text/plain');
|
||||
public static readonly MARKDOWN = new KnownFileType('md', 'text/markdown');
|
||||
public static readonly MERMAID = new KnownFileType('mermaid', 'text/vnd.mermaid');
|
||||
public static readonly JS = new KnownFileType('js', 'application/javascript');
|
||||
public static readonly JPG = new KnownFileType('jpg', 'image/jpeg');
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { type TypeAndName, type TypeAndDisplayName, entries, keys } from './base.ts';
|
||||
import { KnownAmountFormat } from './numeral.ts';
|
||||
import { KnownDateTimeFormat } from './datetime.ts';
|
||||
import { type DateFormatOrder, KnownDateTimeFormat } from './datetime.ts';
|
||||
import { KnownDateTimezoneFormat } from './timezone.ts';
|
||||
import { TransactionType } from './transaction.ts';
|
||||
|
||||
@@ -165,7 +165,7 @@ export class ImportTransactionDataMapping {
|
||||
return result;
|
||||
}
|
||||
|
||||
public parseFileAutoDetectedTimeFormat(fileData: string[][] | undefined): string | undefined {
|
||||
public parseFileAutoDetectedTimeFormat(fileData: string[][] | undefined, longDateTimeFormatOrder: DateFormatOrder, shortDateTimeFormatOrder: DateFormatOrder): string | undefined {
|
||||
if (!fileData || !fileData.length || !this.isColumnMappingSet(ImportTransactionColumnType.TransactionTime)) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -193,7 +193,7 @@ export class ImportTransactionDataMapping {
|
||||
}
|
||||
}
|
||||
|
||||
const detectedFormats = KnownDateTimeFormat.detectMulti(allDateTimes);
|
||||
const detectedFormats = KnownDateTimeFormat.detectMulti(allDateTimes, longDateTimeFormatOrder, shortDateTimeFormatOrder);
|
||||
|
||||
if (!detectedFormats || !detectedFormats.length || detectedFormats.length > 1) {
|
||||
return undefined;
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { type WeekDayValue, WeekDay } from './datetime.ts';
|
||||
import { TimezoneTypeForStatistics } from './timezone.ts';
|
||||
import { CurrencySortingType } from './currency.ts';
|
||||
import {
|
||||
TransactionQuickSaveButtonStyle,
|
||||
TransactionQuickAddButtonActionType
|
||||
} from './transaction.ts';
|
||||
import {
|
||||
CategoricalChartType,
|
||||
TrendChartType,
|
||||
@@ -43,6 +47,8 @@ export interface ApplicationSettings extends BaseApplicationSetting {
|
||||
overviewAccountFilterInHomePage: Record<string, boolean>;
|
||||
overviewTransactionCategoryFilterInHomePage: Record<string, boolean>;
|
||||
// Transaction List Page
|
||||
quickSaveButtonStyleInMobileTransactionListPage: number;
|
||||
quickAddButtonActionInMobileTransactionEditPage: number;
|
||||
itemsCountInTransactionListPage: number;
|
||||
showTotalAmountInTransactionListPage: boolean;
|
||||
showTagInTransactionListPage: boolean;
|
||||
@@ -62,6 +68,9 @@ export interface ApplicationSettings extends BaseApplicationSetting {
|
||||
hideCategoriesWithoutAccounts: boolean;
|
||||
// Exchange Rates Data Page
|
||||
currencySortByInExchangeRatesPage: number;
|
||||
// Browser Cache Management
|
||||
mapCacheExpiration: number,
|
||||
exchangeRatesDataCacheExpiration: number,
|
||||
// Statistics Settings
|
||||
statistics: {
|
||||
defaultChartDataType: number;
|
||||
@@ -107,6 +116,9 @@ export interface WebAuthnConfig {
|
||||
export const ALL_ALLOWED_CLOUD_SYNC_APP_SETTING_KEY_TYPES: Record<string, UserApplicationCloudSettingType> = {
|
||||
// Basic Settings
|
||||
'showAccountBalance': UserApplicationCloudSettingType.Boolean,
|
||||
'autoUpdateExchangeRatesData': UserApplicationCloudSettingType.Boolean,
|
||||
// Navigation Bar
|
||||
'showAddTransactionButtonInDesktopNavbar': UserApplicationCloudSettingType.Boolean,
|
||||
// Overview Page
|
||||
'showAmountInHomePage': UserApplicationCloudSettingType.Boolean,
|
||||
'timezoneUsedForStatisticsInHomePage': UserApplicationCloudSettingType.Number,
|
||||
@@ -117,6 +129,8 @@ export const ALL_ALLOWED_CLOUD_SYNC_APP_SETTING_KEY_TYPES: Record<string, UserAp
|
||||
'showTotalAmountInTransactionListPage': UserApplicationCloudSettingType.Boolean,
|
||||
'showTagInTransactionListPage': UserApplicationCloudSettingType.Boolean,
|
||||
// Transaction Edit Page
|
||||
'quickSaveButtonStyleInMobileTransactionListPage': UserApplicationCloudSettingType.Number,
|
||||
'quickAddButtonActionInMobileTransactionEditPage': UserApplicationCloudSettingType.Number,
|
||||
'autoSaveTransactionDraft': UserApplicationCloudSettingType.String,
|
||||
'autoGetCurrentGeoLocation': UserApplicationCloudSettingType.Boolean,
|
||||
'alwaysShowTransactionPicturesInMobileTransactionEditPage': UserApplicationCloudSettingType.Boolean,
|
||||
@@ -132,6 +146,9 @@ export const ALL_ALLOWED_CLOUD_SYNC_APP_SETTING_KEY_TYPES: Record<string, UserAp
|
||||
'hideCategoriesWithoutAccounts': UserApplicationCloudSettingType.Boolean,
|
||||
// Exchange Rates Data Page
|
||||
'currencySortByInExchangeRatesPage': UserApplicationCloudSettingType.Number,
|
||||
// Browser Cache Management
|
||||
'mapCacheExpiration': UserApplicationCloudSettingType.Number,
|
||||
'exchangeRatesDataCacheExpiration': UserApplicationCloudSettingType.Number,
|
||||
// Statistics Settings
|
||||
'statistics.defaultChartDataType': UserApplicationCloudSettingType.Number,
|
||||
'statistics.defaultTimezoneType': UserApplicationCloudSettingType.Number,
|
||||
@@ -172,6 +189,8 @@ export const DEFAULT_APPLICATION_SETTINGS: ApplicationSettings = {
|
||||
showTotalAmountInTransactionListPage: true,
|
||||
showTagInTransactionListPage: true,
|
||||
// Transaction Edit Page
|
||||
quickSaveButtonStyleInMobileTransactionListPage: TransactionQuickSaveButtonStyle.Default.type,
|
||||
quickAddButtonActionInMobileTransactionEditPage: TransactionQuickAddButtonActionType.Default.type,
|
||||
autoSaveTransactionDraft: 'disabled',
|
||||
autoGetCurrentGeoLocation: false,
|
||||
alwaysShowTransactionPicturesInMobileTransactionEditPage: false,
|
||||
@@ -187,6 +206,9 @@ export const DEFAULT_APPLICATION_SETTINGS: ApplicationSettings = {
|
||||
hideCategoriesWithoutAccounts: false,
|
||||
// Exchange Rates Data Page
|
||||
currencySortByInExchangeRatesPage: CurrencySortingType.Default.type,
|
||||
// Browser Cache Management
|
||||
mapCacheExpiration: -1,
|
||||
exchangeRatesDataCacheExpiration: 0,
|
||||
// Statistics Settings
|
||||
statistics: {
|
||||
defaultChartDataType: ChartDataType.Default.type,
|
||||
|
||||
+16
-9
@@ -1,4 +1,4 @@
|
||||
import type { TypeAndName } from './base.ts';
|
||||
import type { TypeAndName, TypeAndNameWithAlternativeName } from './base.ts';
|
||||
import { DateRange } from '@/core/datetime.ts';
|
||||
|
||||
export enum StatisticsAnalysisType {
|
||||
@@ -88,7 +88,8 @@ export class AccountBalanceTrendChartType implements TypeAndName {
|
||||
|
||||
public static readonly Area = new AccountBalanceTrendChartType(0, 'Area Chart');
|
||||
public static readonly Column = new AccountBalanceTrendChartType(1, 'Column Chart');
|
||||
public static readonly Candlestick = new AccountBalanceTrendChartType(2, 'Candlestick Chart');
|
||||
public static readonly Boxplot = new AccountBalanceTrendChartType(2, 'Boxplot Chart');
|
||||
public static readonly Candlestick = new AccountBalanceTrendChartType(3, 'Candlestick Chart');
|
||||
|
||||
public static readonly Default = TrendChartType.Column;
|
||||
|
||||
@@ -193,24 +194,24 @@ export class ChartDataType implements TypeAndName {
|
||||
}
|
||||
}
|
||||
|
||||
export class ChartSortingType implements TypeAndName {
|
||||
export class ChartSortingType implements TypeAndNameWithAlternativeName {
|
||||
private static readonly allInstances: ChartSortingType[] = [];
|
||||
private static readonly allInstancesByType: Record<number, ChartSortingType> = {};
|
||||
|
||||
public static readonly Amount = new ChartSortingType(0, 'Amount', 'Sort by Amount');
|
||||
public static readonly DisplayOrder = new ChartSortingType(1, 'Display Order', 'Sort by Display Order');
|
||||
public static readonly Name = new ChartSortingType(2, 'Name', 'Sort by Name');
|
||||
public static readonly Amount = new ChartSortingType(0, 'Amount', 'Value');
|
||||
public static readonly DisplayOrder = new ChartSortingType(1, 'Display Order');
|
||||
public static readonly Name = new ChartSortingType(2, 'Name');
|
||||
|
||||
public static readonly Default = ChartSortingType.Amount;
|
||||
|
||||
public readonly type: number;
|
||||
public readonly name: string;
|
||||
public readonly fullName: string;
|
||||
public readonly alternativeName?: string;
|
||||
|
||||
private constructor(type: number, name: string, fullName: string) {
|
||||
private constructor(type: number, name: string, alternativeName?: string) {
|
||||
this.type = type;
|
||||
this.name = name;
|
||||
this.fullName = fullName;
|
||||
this.alternativeName = alternativeName;
|
||||
|
||||
ChartSortingType.allInstances.push(this);
|
||||
ChartSortingType.allInstancesByType[type] = this;
|
||||
@@ -285,6 +286,12 @@ export class ChartDateAggregationType {
|
||||
}
|
||||
}
|
||||
|
||||
export enum ExportMermaidChartType {
|
||||
PieChart = 'pieChart',
|
||||
XYChartBar = 'xyChartBar',
|
||||
XYChartLine = 'xyChartLine'
|
||||
}
|
||||
|
||||
export const DEFAULT_CATEGORICAL_CHART_DATA_RANGE: DateRange = DateRange.ThisMonth;
|
||||
export const DEFAULT_TREND_CHART_DATA_RANGE: DateRange = DateRange.ThisYear;
|
||||
export const DEFAULT_ASSET_TRENDS_CHART_DATA_RANGE: DateRange = DateRange.ThisYear;
|
||||
|
||||
@@ -66,3 +66,66 @@ export class TransactionTagFilterType implements TypeAndName {
|
||||
return TransactionTagFilterType.allInstancesByType[type];
|
||||
}
|
||||
}
|
||||
|
||||
export class TransactionQuickSaveButtonStyle implements TypeAndName {
|
||||
private static readonly allInstances: TransactionQuickSaveButtonStyle[] = [];
|
||||
private static readonly allInstancesByType: Record<number, TransactionQuickSaveButtonStyle> = {};
|
||||
|
||||
public static readonly Disabled = new TransactionQuickSaveButtonStyle(0, 'Disabled');
|
||||
public static readonly BottomFixed = new TransactionQuickSaveButtonStyle(1, 'Bottom Fixed');
|
||||
public static readonly BottomLeftFloating = new TransactionQuickSaveButtonStyle(2, 'Bottom Left Floating');
|
||||
public static readonly BottomCenterFloating = new TransactionQuickSaveButtonStyle(3, 'Bottom Center Floating');
|
||||
public static readonly BottomRightFloating = new TransactionQuickSaveButtonStyle(4, 'Bottom Right Floating');
|
||||
|
||||
public static readonly Default = TransactionQuickSaveButtonStyle.BottomRightFloating;
|
||||
|
||||
public readonly type: number;
|
||||
public readonly name: string;
|
||||
|
||||
private constructor(type: number, name: string) {
|
||||
this.type = type;
|
||||
this.name = name;
|
||||
|
||||
TransactionQuickSaveButtonStyle.allInstances.push(this);
|
||||
TransactionQuickSaveButtonStyle.allInstancesByType[type] = this;
|
||||
}
|
||||
|
||||
public static values(): TransactionQuickSaveButtonStyle[] {
|
||||
return TransactionQuickSaveButtonStyle.allInstances;
|
||||
}
|
||||
|
||||
public static valueOf(type: number): TransactionQuickSaveButtonStyle | undefined {
|
||||
return TransactionQuickSaveButtonStyle.allInstancesByType[type];
|
||||
}
|
||||
}
|
||||
|
||||
export class TransactionQuickAddButtonActionType implements TypeAndName {
|
||||
private static readonly allInstances: TransactionQuickAddButtonActionType[] = [];
|
||||
private static readonly allInstancesByType: Record<number, TransactionQuickAddButtonActionType> = {};
|
||||
|
||||
public static readonly SaveAndGoBack = new TransactionQuickAddButtonActionType(0, 'Save');
|
||||
public static readonly OpenMenu = new TransactionQuickAddButtonActionType(1, 'Open Menu');
|
||||
public static readonly SaveAndAddNewTransaction = new TransactionQuickAddButtonActionType(2, 'Save & New');
|
||||
public static readonly SaveAndKeepCurrentData = new TransactionQuickAddButtonActionType(3, 'Save & Duplicate');
|
||||
|
||||
public static readonly Default = TransactionQuickAddButtonActionType.SaveAndGoBack;
|
||||
|
||||
public readonly type: number;
|
||||
public readonly name: string;
|
||||
|
||||
private constructor(type: number, name: string) {
|
||||
this.type = type;
|
||||
this.name = name;
|
||||
|
||||
TransactionQuickAddButtonActionType.allInstances.push(this);
|
||||
TransactionQuickAddButtonActionType.allInstancesByType[type] = this;
|
||||
}
|
||||
|
||||
public static values(): TransactionQuickAddButtonActionType[] {
|
||||
return TransactionQuickAddButtonActionType.allInstances;
|
||||
}
|
||||
|
||||
public static valueOf(type: number): TransactionQuickAddButtonActionType | undefined {
|
||||
return TransactionQuickAddButtonActionType.allInstancesByType[type];
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -52,7 +52,7 @@ import 'vuetify/styles';
|
||||
|
||||
import * as echarts from 'echarts/core';
|
||||
import { CanvasRenderer } from 'echarts/renderers';
|
||||
import { LineChart, BarChart, PieChart, ScatterChart, CandlestickChart, RadarChart, SankeyChart } from 'echarts/charts';
|
||||
import { LineChart, BarChart, PieChart, ScatterChart, BoxplotChart, CandlestickChart, RadarChart, SankeyChart } from 'echarts/charts';
|
||||
import {
|
||||
GridComponent,
|
||||
TooltipComponent,
|
||||
@@ -503,6 +503,7 @@ echarts.use([
|
||||
BarChart,
|
||||
PieChart,
|
||||
ScatterChart,
|
||||
BoxplotChart,
|
||||
CandlestickChart,
|
||||
RadarChart,
|
||||
SankeyChart,
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const extendMdiSemicolon: string = 'M15.6,2.4H8V10H15.6V6.6Z M5.6,12.4H15.56V18.368L12.368,21.752 H8.4L11.576,18.368H8V12.4Z';
|
||||
@@ -0,0 +1,304 @@
|
||||
import type {
|
||||
BrowserCacheStatistics,
|
||||
SWMapCacheConfig
|
||||
} from '@/core/cache.ts';
|
||||
|
||||
import {
|
||||
SW_PRECACHE_CACHE_NAME_PREFIX,
|
||||
SW_RUNTIME_CACHE_NAME_PREFIX,
|
||||
SW_ASSETS_CACHE_NAME,
|
||||
SW_CODE_CACHE_NAME,
|
||||
SW_MAP_CACHE_NAME,
|
||||
SW_SHARE_CACHE_NAME,
|
||||
SW_MESSAGE_TYPE_UPDATE_MAP_CACHE_CONFIG,
|
||||
SW_MESSAGE_TYPE_UPDATE_MAP_CACHE_CONFIG_RESPONSE,
|
||||
MAP_CACHE_MAX_ENTRIES
|
||||
} from '@/consts/cache.ts';
|
||||
|
||||
import { isFunction, isObject, isNumber } from './common.ts';
|
||||
import services from './services.ts';
|
||||
import logger from './logger.ts';
|
||||
|
||||
let controllerchangeListenerAdded: boolean = false;
|
||||
|
||||
function findFirstCacheName(prefix: string): Promise<string> {
|
||||
if (!window.caches) {
|
||||
logger.error('caches API is not supported in this browser');
|
||||
return Promise.reject(new Error('caches API is not supported'));
|
||||
}
|
||||
|
||||
return window.caches.keys().then(cacheNames => {
|
||||
for (const cacheName of cacheNames) {
|
||||
if (cacheName.startsWith(prefix)) {
|
||||
return cacheName;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`cache with prefix "${prefix}" not found`);
|
||||
});
|
||||
}
|
||||
|
||||
function doUpdateMapCacheExpiration(expireSeconds: number): Promise<void> {
|
||||
const config: SWMapCacheConfig = {
|
||||
enabled: expireSeconds >= 0,
|
||||
patterns: services.getMapProxyTileImageAndAnnotationImageUrlPatterns(),
|
||||
maxEntries: MAP_CACHE_MAX_ENTRIES,
|
||||
maxAgeMilliseconds: expireSeconds * 1000
|
||||
};
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!navigator.serviceWorker || !navigator.serviceWorker.controller) {
|
||||
reject(new Error('Service worker is not supported or not active'));
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = navigator.serviceWorker.controller;
|
||||
|
||||
navigator.serviceWorker.ready.then(() => {
|
||||
const messageChannel = new MessageChannel();
|
||||
|
||||
messageChannel.port1.onmessage = (event) => {
|
||||
if (event.data && event.data.type === SW_MESSAGE_TYPE_UPDATE_MAP_CACHE_CONFIG_RESPONSE) {
|
||||
logger.info('Map cache config updated successfully in service worker: ' + JSON.stringify(event.data.payload));
|
||||
resolve();
|
||||
} else {
|
||||
logger.error('cannot update map cache config, invalid response from service worker', event);
|
||||
reject(new Error('Invalid response from service worker'));
|
||||
}
|
||||
};
|
||||
|
||||
controller.postMessage({
|
||||
type: SW_MESSAGE_TYPE_UPDATE_MAP_CACHE_CONFIG,
|
||||
payload: config
|
||||
}, [messageChannel.port2]);
|
||||
}).catch(error => {
|
||||
logger.error('failed to update map cache config', error);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function getCacheTotalSize(cacheName: string): Promise<number> {
|
||||
if (!window.caches) {
|
||||
logger.error('caches API is not supported in this browser');
|
||||
return Promise.reject(new Error('caches API is not supported'));
|
||||
}
|
||||
|
||||
const cache = await window.caches.open(cacheName);
|
||||
const requests = await cache.keys();
|
||||
let totalSize = 0;
|
||||
|
||||
for (const request of requests) {
|
||||
try {
|
||||
const response = await cache.match(request);
|
||||
|
||||
if (response) {
|
||||
const blob = await response.clone().blob();
|
||||
totalSize += blob.size;
|
||||
}
|
||||
} catch (ex) {
|
||||
logger.warn(`failed to get size for request ${request.url} in cache ${cacheName}`, ex);
|
||||
}
|
||||
}
|
||||
|
||||
return totalSize;
|
||||
}
|
||||
|
||||
export function getShareCacheImageBlob(): Promise<Blob | undefined> {
|
||||
if (!window.caches) {
|
||||
logger.error('caches API is not supported in this browser');
|
||||
return Promise.resolve(undefined);
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
window.caches.open(SW_SHARE_CACHE_NAME).then(cache => {
|
||||
cache.match(SW_SHARE_CACHE_NAME).then(response => {
|
||||
if (!response) {
|
||||
resolve(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
response.blob().then(blob => {
|
||||
cache.delete(SW_SHARE_CACHE_NAME).then(() => {
|
||||
resolve(blob);
|
||||
}).catch(error => {
|
||||
logger.warn('failed to delete share cache image blob', error);
|
||||
resolve(blob);
|
||||
});
|
||||
}).catch(error => {
|
||||
logger.error('failed to read share cache image blob', error);
|
||||
resolve(undefined);
|
||||
});
|
||||
}).catch(error => {
|
||||
logger.error('failed to match share cache image blob', error);
|
||||
resolve(undefined);
|
||||
});
|
||||
}).catch(error => {
|
||||
logger.error('failed to open share cache', error);
|
||||
resolve(undefined);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function loadBrowserCacheStatistics(): Promise<BrowserCacheStatistics> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const caches = window.caches;
|
||||
|
||||
if (!caches) {
|
||||
logger.error('caches API is not supported in this browser');
|
||||
reject(new Error('caches API is not supported in this browser'));
|
||||
return;
|
||||
}
|
||||
|
||||
return Promise.all([
|
||||
navigator && navigator.storage && isFunction(navigator.storage.estimate) ? navigator.storage.estimate() : Promise.resolve(undefined),
|
||||
findFirstCacheName(SW_PRECACHE_CACHE_NAME_PREFIX).then(cacheName => getCacheTotalSize(cacheName)).catch(() => 0),
|
||||
findFirstCacheName(SW_RUNTIME_CACHE_NAME_PREFIX).then(cacheName => getCacheTotalSize(cacheName)).catch(() => 0),
|
||||
getCacheTotalSize(SW_CODE_CACHE_NAME),
|
||||
getCacheTotalSize(SW_ASSETS_CACHE_NAME),
|
||||
getCacheTotalSize(SW_MAP_CACHE_NAME)
|
||||
]).then(([storageEstimate, precacheCacheSize, runtimeCacheSize, codeCacheSize, assetsCacheSize, mapCacheSize]) => {
|
||||
let totalCacheSize: number = 0;
|
||||
|
||||
if (storageEstimate) {
|
||||
const cachesUsage = 'usageDetails' in storageEstimate
|
||||
&& isObject(storageEstimate.usageDetails)
|
||||
&& 'caches' in storageEstimate.usageDetails
|
||||
? storageEstimate.usageDetails.caches : undefined;
|
||||
|
||||
if (isNumber(cachesUsage)) {
|
||||
totalCacheSize = cachesUsage;
|
||||
} else if (isNumber(storageEstimate.usage)) {
|
||||
totalCacheSize = storageEstimate.usage;
|
||||
}
|
||||
}
|
||||
|
||||
if (totalCacheSize < 1) {
|
||||
totalCacheSize = precacheCacheSize + runtimeCacheSize + codeCacheSize + assetsCacheSize + mapCacheSize;
|
||||
}
|
||||
|
||||
let othersCacheSize: number = totalCacheSize - precacheCacheSize - runtimeCacheSize - codeCacheSize - assetsCacheSize - mapCacheSize;
|
||||
|
||||
if (othersCacheSize < 0) {
|
||||
othersCacheSize = 0;
|
||||
}
|
||||
|
||||
resolve({
|
||||
totalCacheSize: totalCacheSize,
|
||||
codeCacheSize: codeCacheSize + runtimeCacheSize,
|
||||
assetsCacheSize: assetsCacheSize + precacheCacheSize,
|
||||
mapCacheSize: mapCacheSize,
|
||||
othersCacheSize: othersCacheSize
|
||||
});
|
||||
}).catch(error => {
|
||||
logger.error("failed to clear cache", error);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function updateMapCacheExpiration(expireSeconds: number): void {
|
||||
if ('serviceWorker' in navigator) {
|
||||
if (!controllerchangeListenerAdded) {
|
||||
navigator.serviceWorker.addEventListener('controllerchange', () => {
|
||||
doUpdateMapCacheExpiration(expireSeconds);
|
||||
});
|
||||
controllerchangeListenerAdded = true;
|
||||
}
|
||||
|
||||
if (navigator.serviceWorker.controller) {
|
||||
doUpdateMapCacheExpiration(expireSeconds);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function clearCaches(cacheNames: string[], cacheNamePrefixes?: string[]): Promise<void> {
|
||||
if (!window.caches) {
|
||||
logger.error('caches API is not supported in this browser');
|
||||
return Promise.reject();
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const promises = [];
|
||||
|
||||
for (const cacheName of cacheNames) {
|
||||
promises.push(window.caches.delete(cacheName).then(success => {
|
||||
if (success) {
|
||||
logger.info(`cache "${cacheName}" cleared successfully`);
|
||||
return Promise.resolve(cacheName);
|
||||
} else {
|
||||
logger.warn(`failed to clear cache "${cacheName}"`);
|
||||
return Promise.reject(cacheName);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
if (cacheNamePrefixes) {
|
||||
for (const prefix of cacheNamePrefixes) {
|
||||
promises.push(findFirstCacheName(prefix).then(cacheName => {
|
||||
return window.caches.delete(cacheName).then(success => {
|
||||
if (success) {
|
||||
logger.info(`cache "${cacheName}" cleared successfully`);
|
||||
return Promise.resolve(cacheName);
|
||||
} else {
|
||||
logger.warn(`failed to clear cache "${cacheName}"`);
|
||||
return Promise.reject(cacheName);
|
||||
}
|
||||
});
|
||||
}).catch(error => {
|
||||
logger.warn(`cache with prefix "${prefix}" not found`, error);
|
||||
return Promise.resolve();
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
Promise.all(promises).then(() => {
|
||||
resolve();
|
||||
}).catch(() => {
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function clearApplicationCodeCache(): Promise<void> {
|
||||
return clearCaches([SW_CODE_CACHE_NAME], [SW_RUNTIME_CACHE_NAME_PREFIX]);
|
||||
}
|
||||
|
||||
export function clearMapDataCache(): Promise<void> {
|
||||
return clearCaches([SW_MAP_CACHE_NAME]);
|
||||
}
|
||||
|
||||
export function clearAllBrowserCaches(): Promise<void> {
|
||||
if (!window.caches) {
|
||||
logger.error('caches API is not supported in this browser');
|
||||
return Promise.reject();
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
window.caches.keys().then(cacheNames => {
|
||||
const promises = [];
|
||||
|
||||
for (const cacheName of cacheNames) {
|
||||
promises.push(window.caches.delete(cacheName).then(success => {
|
||||
if (success) {
|
||||
logger.info(`cache "${cacheName}" cleared successfully`);
|
||||
return Promise.resolve(cacheName);
|
||||
} else {
|
||||
logger.warn(`failed to clear cache "${cacheName}"`);
|
||||
return Promise.reject(cacheName);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
Promise.all(promises).then(() => {
|
||||
logger.info("all caches cleared successfully");
|
||||
resolve();
|
||||
}).catch(() => {
|
||||
resolve();
|
||||
});
|
||||
}).catch(error => {
|
||||
logger.error("failed to clear cache", error);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -48,7 +48,7 @@ const localeData: ChineseCalendarLocaleData = {
|
||||
const ordinalSuffix = ['st', 'nd', 'rd'];
|
||||
|
||||
describe('getChineseYearMonthAllDayInfos', () => {
|
||||
const lines: string[] = fs.readFileSync(path.join(__dirname, 'chinese_calendar_all_data.txt'), 'utf8').split('\n');
|
||||
const lines: string[] = fs.readFileSync(path.join(__dirname, 'chinese_calendar_all_data.txt'), 'utf8').replace(/\r/g, '').split('\n');
|
||||
const allMonthChineseDays: Record<string, string[]> = {};
|
||||
const allMonthSolarTermNames: Record<string, string[]> = {};
|
||||
let currentMonthChineseDays: string[] = [];
|
||||
@@ -121,7 +121,7 @@ describe('getChineseYearMonthAllDayInfos', () => {
|
||||
});
|
||||
|
||||
describe('getChineseYearMonthDayInfo', () => {
|
||||
const lines: string[] = fs.readFileSync(path.join(__dirname, 'chinese_calendar_all_data.txt'), 'utf8').split('\n');
|
||||
const lines: string[] = fs.readFileSync(path.join(__dirname, 'chinese_calendar_all_data.txt'), 'utf8').replace(/\r/g, '').split('\n');
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim() || line.startsWith('#')) {
|
||||
|
||||
+9
-3
@@ -1,5 +1,11 @@
|
||||
import { keys, keysIfValueEquals, values } from '@/core/base.ts';
|
||||
import type { NameValue, TypeAndName, TypeAndDisplayName} from '@/core/base.ts';
|
||||
import {
|
||||
type GenericNameValue,
|
||||
type TypeAndName,
|
||||
type TypeAndDisplayName,
|
||||
keys,
|
||||
keysIfValueEquals,
|
||||
values
|
||||
} from '@/core/base.ts';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
|
||||
export function isFunction(val: unknown): val is Function {
|
||||
@@ -285,7 +291,7 @@ export function getItemByKeyValue<T>(src: Record<string, T>[] | Record<string, R
|
||||
return null;
|
||||
}
|
||||
|
||||
export function findNameByValue(items: NameValue[], value: string): string | null {
|
||||
export function findNameByValue<T>(items: GenericNameValue<T>[], value: T): string | null {
|
||||
for (const item of items) {
|
||||
if (item.value === value) {
|
||||
return item.name;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user