mirror of
https://github.com/eitchtee/WYGIWYH.git
synced 2026-02-26 09:24:51 +01:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
24a1ef2d0a | ||
|
|
163f2f4e5b | ||
|
|
ede63acf5f | ||
|
|
a8ba3d8754 | ||
|
|
e2f1156264 | ||
|
|
d5bbad7887 | ||
|
|
7ebacff6e4 | ||
|
|
df8ef5d04c | ||
|
|
fa2a8b8c65 | ||
|
|
e44ac5dab6 | ||
|
|
f9261d1283 | ||
|
|
4c73c1cae5 | ||
|
|
0315a56f88 | ||
|
|
44d6b8b53c |
@@ -106,6 +106,17 @@ class ExcelImportSettings(BaseModel):
|
||||
sheets: list[str] | str = "*"
|
||||
|
||||
|
||||
class QIFImportSettings(BaseModel):
|
||||
skip_errors: bool = Field(
|
||||
default=False,
|
||||
description="If True, errors during import will be logged and skipped",
|
||||
)
|
||||
file_type: Literal["qif"] = "qif"
|
||||
importing: Literal["transactions"] = "transactions"
|
||||
encoding: str = Field(default="utf-8", description="File encoding")
|
||||
date_format: str = Field(..., description="Date format (e.g. %d/%m/%Y)")
|
||||
|
||||
|
||||
class ColumnMapping(BaseModel):
|
||||
source: Optional[str] | Optional[list[str]] = Field(
|
||||
default=None,
|
||||
@@ -342,7 +353,7 @@ class CurrencyExchangeMapping(ColumnMapping):
|
||||
|
||||
|
||||
class ImportProfileSchema(BaseModel):
|
||||
settings: CSVImportSettings | ExcelImportSettings
|
||||
settings: CSVImportSettings | ExcelImportSettings | QIFImportSettings
|
||||
mapping: Dict[
|
||||
str,
|
||||
TransactionAccountMapping
|
||||
|
||||
@@ -3,6 +3,8 @@ import hashlib
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import zipfile
|
||||
from django.db import transaction
|
||||
from datetime import datetime, date
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from typing import Dict, Any, Literal, Union
|
||||
@@ -845,6 +847,219 @@ class ImportService:
|
||||
f"Invalid {self.settings.file_type.upper()} file format: {str(e)}"
|
||||
)
|
||||
|
||||
def _parse_and_import_qif(self, content_lines: list[str], filename: str) -> None:
|
||||
# Infer account from filename (remove extension)
|
||||
account_name = os.path.splitext(os.path.basename(filename))[0]
|
||||
|
||||
current_transaction = {}
|
||||
raw_lines_buffer = []
|
||||
|
||||
account = Account.objects.filter(name=account_name).first()
|
||||
if not account:
|
||||
raise ValueError(f"Account '{account_name}' not found.")
|
||||
|
||||
row_number = 0
|
||||
for line in content_lines:
|
||||
row_number += 1
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
raw_lines_buffer.append(line)
|
||||
|
||||
if line == "^":
|
||||
if current_transaction:
|
||||
# Deduplication using hash of raw lines
|
||||
raw_content = "".join(raw_lines_buffer)
|
||||
internal_id = hashlib.sha256(
|
||||
raw_content.encode("utf-8")
|
||||
).hexdigest()
|
||||
|
||||
# Reset buffer for next transaction
|
||||
raw_lines_buffer = []
|
||||
|
||||
try:
|
||||
with transaction.atomic():
|
||||
if Transaction.objects.filter(
|
||||
internal_id=internal_id
|
||||
).exists():
|
||||
self._increment_totals("skipped", 1)
|
||||
self._log(
|
||||
"info",
|
||||
f"Skipped duplicate transaction from {filename}",
|
||||
)
|
||||
current_transaction = {}
|
||||
continue
|
||||
|
||||
# Handle Account
|
||||
if account:
|
||||
current_transaction["account"] = account
|
||||
else:
|
||||
acc = Account.objects.filter(name=account_name).first()
|
||||
if acc:
|
||||
current_transaction["account"] = acc
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Account '{account_name}' not found."
|
||||
)
|
||||
|
||||
current_transaction["internal_id"] = internal_id
|
||||
|
||||
# Handle Description/Memo mapping
|
||||
if "memo" in current_transaction:
|
||||
current_transaction["description"] = (
|
||||
current_transaction.pop("memo")
|
||||
)
|
||||
|
||||
# Handle Payee mapping
|
||||
entities = []
|
||||
if "payee" in current_transaction:
|
||||
payee_name = current_transaction.pop("payee")
|
||||
# "Treat the payee (P) as the entity. Use existing or create"
|
||||
entity, _ = TransactionEntity.objects.get_or_create(
|
||||
name=payee_name
|
||||
)
|
||||
entities.append(entity)
|
||||
|
||||
# Handle Label/Category
|
||||
category = None
|
||||
tags = []
|
||||
if "label" in current_transaction:
|
||||
label = current_transaction.pop("label")
|
||||
if label.startswith("[") and label.endswith("]"):
|
||||
# Transfer: set label as description, ignore category/tags
|
||||
clean_label = label[1:-1]
|
||||
current_transaction["description"] = clean_label
|
||||
else:
|
||||
parts = label.split(":")
|
||||
if parts:
|
||||
cat_name = parts[0].strip()
|
||||
if cat_name:
|
||||
category, _ = (
|
||||
TransactionCategory.objects.get_or_create(
|
||||
name=cat_name
|
||||
)
|
||||
)
|
||||
|
||||
if len(parts) > 1:
|
||||
for tag_name in parts[1:]:
|
||||
tag_name = tag_name.strip()
|
||||
if tag_name:
|
||||
tag, _ = (
|
||||
TransactionTag.objects.get_or_create(
|
||||
name=tag_name
|
||||
)
|
||||
)
|
||||
tags.append(tag)
|
||||
|
||||
current_transaction["category"] = category
|
||||
|
||||
# Create transaction
|
||||
new_trans = Transaction.objects.create(
|
||||
**current_transaction
|
||||
)
|
||||
if entities:
|
||||
new_trans.entities.set(entities)
|
||||
if tags:
|
||||
new_trans.tags.set(tags)
|
||||
|
||||
self.import_run.transactions.add(new_trans)
|
||||
self._increment_totals("successful", 1)
|
||||
|
||||
except Exception as e:
|
||||
if not self.settings.skip_errors:
|
||||
raise e
|
||||
self._log(
|
||||
"warning",
|
||||
f"Error processing transaction in {filename}: {str(e)}",
|
||||
)
|
||||
self._increment_totals("failed", 1)
|
||||
|
||||
# Reset for next transaction
|
||||
current_transaction = {}
|
||||
else:
|
||||
# Empty transaction record (orphaned ^)
|
||||
raw_lines_buffer = []
|
||||
pass
|
||||
self._increment_totals("processed", 1)
|
||||
continue
|
||||
|
||||
if line.startswith("!"):
|
||||
continue
|
||||
|
||||
code = line[0]
|
||||
value = line[1:]
|
||||
|
||||
if code == "D":
|
||||
try:
|
||||
current_transaction["date"] = datetime.strptime(
|
||||
value, self.settings.date_format
|
||||
).date()
|
||||
except ValueError:
|
||||
self._log(
|
||||
"warning",
|
||||
f"Could not parse date '{value}' using format '{self.settings.date_format}' in {filename}",
|
||||
)
|
||||
if not self.settings.skip_errors:
|
||||
raise ValueError(f"Invalid date format '{value}'")
|
||||
|
||||
elif code == "T":
|
||||
try:
|
||||
cleaned_value = value.replace(",", "")
|
||||
amount = Decimal(cleaned_value)
|
||||
if amount < 0:
|
||||
current_transaction["type"] = Transaction.Type.EXPENSE
|
||||
current_transaction["amount"] = abs(amount)
|
||||
else:
|
||||
current_transaction["type"] = Transaction.Type.INCOME
|
||||
current_transaction["amount"] = amount
|
||||
except InvalidOperation:
|
||||
self._log(
|
||||
"warning", f"Could not parse amount '{value}' in {filename}"
|
||||
)
|
||||
if not self.settings.skip_errors:
|
||||
raise ValueError(f"Invalid amount format '{value}'")
|
||||
|
||||
elif code == "P":
|
||||
current_transaction["payee"] = value
|
||||
elif code == "M":
|
||||
current_transaction["memo"] = value
|
||||
elif code == "L":
|
||||
current_transaction["label"] = value
|
||||
elif code == "N":
|
||||
pass
|
||||
|
||||
def _process_qif(self, file_path):
|
||||
def process_logic():
|
||||
if zipfile.is_zipfile(file_path):
|
||||
try:
|
||||
with zipfile.ZipFile(file_path, "r") as zf:
|
||||
for filename in zf.namelist():
|
||||
if filename.lower().endswith(
|
||||
".qif"
|
||||
) and not filename.startswith("__MACOSX"):
|
||||
self._log(
|
||||
"info", f"Processing QIF from ZIP: {filename}"
|
||||
)
|
||||
with zf.open(filename) as f:
|
||||
content = f.read().decode(self.settings.encoding)
|
||||
self._parse_and_import_qif(
|
||||
content.splitlines(), filename
|
||||
)
|
||||
except Exception as e:
|
||||
raise ValueError(f"Error processing ZIP file: {str(e)}")
|
||||
else:
|
||||
with open(file_path, "r", encoding=self.settings.encoding) as f:
|
||||
self._parse_and_import_qif(
|
||||
f.readlines(), os.path.basename(file_path)
|
||||
)
|
||||
|
||||
if not self.settings.skip_errors:
|
||||
with transaction.atomic():
|
||||
process_logic()
|
||||
else:
|
||||
process_logic()
|
||||
|
||||
def _validate_file_path(self, file_path: str) -> str:
|
||||
"""
|
||||
Validates that the file path is within the allowed temporary directory.
|
||||
@@ -871,6 +1086,8 @@ class ImportService:
|
||||
self._process_csv(file_path)
|
||||
elif isinstance(self.settings, version_1.ExcelImportSettings):
|
||||
self._process_excel(file_path)
|
||||
elif isinstance(self.settings, version_1.QIFImportSettings):
|
||||
self._process_qif(file_path)
|
||||
|
||||
self._update_status("FINISHED")
|
||||
self._log(
|
||||
|
||||
259
app/apps/import_app/tests/test_qif_import.py
Normal file
259
app/apps/import_app/tests/test_qif_import.py
Normal file
@@ -0,0 +1,259 @@
|
||||
from decimal import Decimal
|
||||
import os
|
||||
import shutil
|
||||
from django.test import TestCase
|
||||
from django.contrib.auth import get_user_model
|
||||
from apps.accounts.models import Account, AccountGroup
|
||||
from apps.currencies.models import Currency
|
||||
from apps.common.middleware.thread_local import write_current_user, delete_current_user
|
||||
from apps.import_app.models import ImportProfile, ImportRun
|
||||
from apps.import_app.services.v1 import ImportService
|
||||
from apps.transactions.models import (
|
||||
Transaction,
|
||||
)
|
||||
|
||||
|
||||
class QIFImportTests(TestCase):
|
||||
def setUp(self):
|
||||
# Patch TEMP_DIR for testing
|
||||
self.original_temp_dir = ImportService.TEMP_DIR
|
||||
self.test_dir = os.path.abspath("temp_test_import")
|
||||
ImportService.TEMP_DIR = self.test_dir
|
||||
os.makedirs(self.test_dir, exist_ok=True)
|
||||
|
||||
# Create user and set context
|
||||
User = get_user_model()
|
||||
self.user = User.objects.create_user(
|
||||
email="test@example.com", password="password"
|
||||
)
|
||||
write_current_user(self.user)
|
||||
|
||||
self.currency = Currency.objects.create(
|
||||
code="BRL", name="Real", decimal_places=2, prefix="R$ "
|
||||
)
|
||||
self.group = AccountGroup.objects.create(name="Test Group", owner=self.user)
|
||||
self.account = Account.objects.create(
|
||||
name="bradesco-checking",
|
||||
group=self.group,
|
||||
currency=self.currency,
|
||||
owner=self.user,
|
||||
)
|
||||
|
||||
def tearDown(self):
|
||||
delete_current_user()
|
||||
ImportService.TEMP_DIR = self.original_temp_dir
|
||||
if os.path.exists(self.test_dir):
|
||||
shutil.rmtree(self.test_dir)
|
||||
|
||||
def test_import_single_qif_valid_mapping(self):
|
||||
content = """!Type:Bank
|
||||
D04/01/2015
|
||||
T8069.46
|
||||
PMy Payee -> Entity
|
||||
MNote -> Desc
|
||||
LOld Cat:New Tag
|
||||
^
|
||||
D05/01/2015
|
||||
T-100.00
|
||||
PSupermarket
|
||||
MWeekly shopping
|
||||
L[Transfer]
|
||||
^
|
||||
"""
|
||||
filename = "bradesco-checking.qif"
|
||||
file_path = os.path.join(self.test_dir, filename)
|
||||
with open(file_path, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
|
||||
yaml_config = """
|
||||
settings:
|
||||
file_type: qif
|
||||
importing: transactions
|
||||
date_format: "%d/%m/%Y"
|
||||
mapping: {}
|
||||
"""
|
||||
profile = ImportProfile.objects.create(
|
||||
name="QIF Profile",
|
||||
yaml_config=yaml_config,
|
||||
version=ImportProfile.Versions.VERSION_1,
|
||||
)
|
||||
run = ImportRun.objects.create(profile=profile, file_name=filename)
|
||||
service = ImportService(run)
|
||||
|
||||
service.process_file(file_path)
|
||||
|
||||
self.assertEqual(Transaction.objects.count(), 2)
|
||||
|
||||
# Transaction 1: Income, Category+Tag
|
||||
t1 = Transaction.objects.get(description="Note -> Desc")
|
||||
self.assertEqual(t1.amount, Decimal("8069.46"))
|
||||
self.assertEqual(t1.type, Transaction.Type.INCOME)
|
||||
self.assertEqual(t1.category.name, "Old Cat")
|
||||
self.assertTrue(t1.tags.filter(name="New Tag").exists())
|
||||
self.assertTrue(t1.entities.filter(name="My Payee -> Entity").exists())
|
||||
self.assertEqual(t1.account, self.account)
|
||||
|
||||
# Transaction 2: Expense, Transfer ([Transfer] -> Description)
|
||||
t2 = Transaction.objects.get(description="Transfer")
|
||||
self.assertEqual(t2.amount, Decimal("100.00"))
|
||||
self.assertEqual(t2.type, Transaction.Type.EXPENSE)
|
||||
self.assertIsNone(t2.category)
|
||||
self.assertFalse(t2.tags.exists())
|
||||
self.assertTrue(t2.entities.filter(name="Supermarket").exists())
|
||||
self.assertEqual(t2.description, "Transfer")
|
||||
|
||||
def test_import_deduplication_hash(self):
|
||||
# Same content twice. Should result in only 1 transaction due to hash deduplication.
|
||||
content = """!Type:Bank
|
||||
D04/01/2015
|
||||
T100.00
|
||||
POK
|
||||
^
|
||||
"""
|
||||
filename = "bradesco-checking.qif"
|
||||
file_path = os.path.join(self.test_dir, filename)
|
||||
with open(file_path, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
|
||||
yaml_config = """
|
||||
settings:
|
||||
file_type: qif
|
||||
importing: transactions
|
||||
date_format: "%d/%m/%Y"
|
||||
mapping: {}
|
||||
"""
|
||||
profile = ImportProfile.objects.create(
|
||||
name="QIF Profile",
|
||||
yaml_config=yaml_config,
|
||||
version=ImportProfile.Versions.VERSION_1,
|
||||
)
|
||||
run = ImportRun.objects.create(profile=profile, file_name=filename)
|
||||
service = ImportService(run)
|
||||
|
||||
# First run
|
||||
service.process_file(file_path)
|
||||
self.assertEqual(Transaction.objects.count(), 1)
|
||||
|
||||
# Service deletes file after processing, so recreate it for second run
|
||||
with open(file_path, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
|
||||
# Second run - Duplicate content
|
||||
service.process_file(file_path)
|
||||
self.assertEqual(Transaction.objects.count(), 1)
|
||||
|
||||
def test_import_strict_error_rollback(self):
|
||||
# atomic check.
|
||||
# Transaction 1 valid, Transaction 2 invalid date.
|
||||
content = """!Type:Bank
|
||||
D04/01/2015
|
||||
T100.00
|
||||
POK
|
||||
^
|
||||
DINVALID
|
||||
T100.00
|
||||
PBad
|
||||
^
|
||||
"""
|
||||
filename = "bradesco-checking.qif"
|
||||
file_path = os.path.join(self.test_dir, filename)
|
||||
with open(file_path, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
|
||||
yaml_config = """
|
||||
settings:
|
||||
file_type: qif
|
||||
importing: transactions
|
||||
date_format: "%d/%m/%Y"
|
||||
skip_errors: false
|
||||
mapping: {}
|
||||
"""
|
||||
profile = ImportProfile.objects.create(
|
||||
name="QIF Profile",
|
||||
yaml_config=yaml_config,
|
||||
version=ImportProfile.Versions.VERSION_1,
|
||||
)
|
||||
run = ImportRun.objects.create(profile=profile, file_name=filename)
|
||||
service = ImportService(run)
|
||||
|
||||
with self.assertRaises(Exception) as cm:
|
||||
service.process_file(file_path)
|
||||
self.assertEqual(str(cm.exception), "Import failed")
|
||||
|
||||
# Should be 0 transactions because of atomic rollback
|
||||
self.assertEqual(Transaction.objects.count(), 0)
|
||||
|
||||
def test_import_missing_account(self):
|
||||
# File with account name that doesn't exist
|
||||
content = """!Type:Bank
|
||||
D04/01/2015
|
||||
T100.00
|
||||
POK
|
||||
^
|
||||
"""
|
||||
filename = "missing-account.qif"
|
||||
file_path = os.path.join(self.test_dir, filename)
|
||||
with open(file_path, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
|
||||
yaml_config = """
|
||||
settings:
|
||||
file_type: qif
|
||||
importing: transactions
|
||||
date_format: "%d/%m/%Y"
|
||||
mapping: {}
|
||||
"""
|
||||
profile = ImportProfile.objects.create(
|
||||
name="QIF Profile",
|
||||
yaml_config=yaml_config,
|
||||
version=ImportProfile.Versions.VERSION_1,
|
||||
)
|
||||
run = ImportRun.objects.create(profile=profile, file_name=filename)
|
||||
service = ImportService(run)
|
||||
|
||||
# Should fail because account doesn't exist
|
||||
with self.assertRaises(Exception) as cm:
|
||||
service.process_file(file_path)
|
||||
self.assertEqual(str(cm.exception), "Import failed")
|
||||
|
||||
def test_import_skip_errors(self):
|
||||
# skip_errors: true.
|
||||
# Transaction 1 valid, Transaction 2 invalid date.
|
||||
content = """!Type:Bank
|
||||
D04/01/2015
|
||||
T100.00
|
||||
POK
|
||||
^
|
||||
DINVALID
|
||||
T100.00
|
||||
PBad
|
||||
^
|
||||
"""
|
||||
filename = "bradesco-checking.qif"
|
||||
file_path = os.path.join(self.test_dir, filename)
|
||||
with open(file_path, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
|
||||
yaml_config = """
|
||||
settings:
|
||||
file_type: qif
|
||||
importing: transactions
|
||||
date_format: "%d/%m/%Y"
|
||||
skip_errors: true
|
||||
mapping: {}
|
||||
"""
|
||||
profile = ImportProfile.objects.create(
|
||||
name="QIF Profile",
|
||||
yaml_config=yaml_config,
|
||||
version=ImportProfile.Versions.VERSION_1,
|
||||
)
|
||||
run = ImportRun.objects.create(profile=profile, file_name=filename)
|
||||
service = ImportService(run)
|
||||
|
||||
service.process_file(file_path)
|
||||
|
||||
# Should be 1 transaction (valid one)
|
||||
self.assertEqual(Transaction.objects.count(), 1)
|
||||
self.assertEqual(
|
||||
Transaction.objects.first().description, ""
|
||||
) # empty desc if no memo
|
||||
10
app/import_presets/qif_standard/config.yml
Normal file
10
app/import_presets/qif_standard/config.yml
Normal file
@@ -0,0 +1,10 @@
|
||||
settings:
|
||||
file_type: qif
|
||||
importing: transactions
|
||||
encoding: cp1252
|
||||
date_format: "%d/%m/%Y"
|
||||
skip_errors: true
|
||||
|
||||
mapping: {}
|
||||
|
||||
deduplicate: []
|
||||
7
app/import_presets/qif_standard/manifest.json
Normal file
7
app/import_presets/qif_standard/manifest.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"author": "eitchtee",
|
||||
"description": "Standard QIF Import. Mapping is automatic.",
|
||||
"schema_version": 1,
|
||||
"name": "Standard QIF",
|
||||
"message": "Account is inferred from filename (e.g., 'Checking.qif' -> Account 'Checking').\nYou might need to change the date format to match the date format on your file."
|
||||
}
|
||||
@@ -8,8 +8,8 @@ msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-01-10 20:50+0000\n"
|
||||
"PO-Revision-Date: 2025-12-16 05:24+0000\n"
|
||||
"Last-Translator: BRodolfo <simplysmartbydesign@gmail.com>\n"
|
||||
"PO-Revision-Date: 2026-01-18 21:24+0000\n"
|
||||
"Last-Translator: Juan David Afanador <juanafanador07@gmail.com>\n"
|
||||
"Language-Team: Spanish <https://translations.herculino.com/projects/wygiwyh/"
|
||||
"app/es/>\n"
|
||||
"Language: es\n"
|
||||
@@ -17,7 +17,7 @@ msgstr ""
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=2; plural=n != 1;\n"
|
||||
"X-Generator: Weblate 5.14.3\n"
|
||||
"X-Generator: Weblate 5.15.1\n"
|
||||
|
||||
#: apps/accounts/forms.py:24
|
||||
msgid "Group name"
|
||||
@@ -1353,10 +1353,8 @@ msgid "Transaction Type"
|
||||
msgstr "Tipo de Transacción"
|
||||
|
||||
#: apps/transactions/filters.py:89
|
||||
#, fuzzy
|
||||
#| msgid "Status"
|
||||
msgid "Mute Status"
|
||||
msgstr "Estado"
|
||||
msgstr "Silenciada"
|
||||
|
||||
#: apps/transactions/filters.py:94
|
||||
msgid "Date from"
|
||||
@@ -2696,8 +2694,8 @@ msgstr "Última sincronización"
|
||||
#, python-format
|
||||
msgid "%(counter)s consecutive failure"
|
||||
msgid_plural "%(counter)s consecutive failures"
|
||||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
msgstr[0] "%(counter)s fallo consecutivo"
|
||||
msgstr[1] "%(counter)s fallos consecutivos"
|
||||
|
||||
#: templates/exchange_rates_services/fragments/list.html:69
|
||||
msgid "currencies"
|
||||
@@ -3012,72 +3010,60 @@ msgstr "No hay transacciones recientes"
|
||||
|
||||
#: templates/insights/fragments/month_by_month.html:86
|
||||
#: templates/insights/fragments/year_by_year.html:54
|
||||
#, fuzzy
|
||||
#| msgid "Tags"
|
||||
msgid "Tag"
|
||||
msgstr "Etiquetas"
|
||||
msgstr "Etiqueta"
|
||||
|
||||
#: templates/insights/fragments/month_by_month.html:94
|
||||
msgid "Jan"
|
||||
msgstr ""
|
||||
msgstr "Ene"
|
||||
|
||||
#: templates/insights/fragments/month_by_month.html:95
|
||||
msgid "Feb"
|
||||
msgstr ""
|
||||
msgstr "Feb"
|
||||
|
||||
#: templates/insights/fragments/month_by_month.html:96
|
||||
#, fuzzy
|
||||
#| msgid "Max"
|
||||
msgid "Mar"
|
||||
msgstr "Máximo"
|
||||
msgstr "Mar"
|
||||
|
||||
#: templates/insights/fragments/month_by_month.html:97
|
||||
msgid "Apr"
|
||||
msgstr ""
|
||||
msgstr "Abr"
|
||||
|
||||
#: templates/insights/fragments/month_by_month.html:98
|
||||
#, fuzzy
|
||||
#| msgid "Max"
|
||||
msgid "May"
|
||||
msgstr "Máximo"
|
||||
msgstr "May"
|
||||
|
||||
#: templates/insights/fragments/month_by_month.html:99
|
||||
msgid "Jun"
|
||||
msgstr ""
|
||||
msgstr "Jun"
|
||||
|
||||
#: templates/insights/fragments/month_by_month.html:100
|
||||
msgid "Jul"
|
||||
msgstr ""
|
||||
msgstr "Jul"
|
||||
|
||||
#: templates/insights/fragments/month_by_month.html:101
|
||||
msgid "Aug"
|
||||
msgstr ""
|
||||
msgstr "Ago"
|
||||
|
||||
#: templates/insights/fragments/month_by_month.html:102
|
||||
#, fuzzy
|
||||
#| msgid "Set"
|
||||
msgid "Sep"
|
||||
msgstr "Establecer"
|
||||
msgstr "Sep"
|
||||
|
||||
#: templates/insights/fragments/month_by_month.html:103
|
||||
msgid "Oct"
|
||||
msgstr ""
|
||||
msgstr "Oct"
|
||||
|
||||
#: templates/insights/fragments/month_by_month.html:104
|
||||
#, fuzzy
|
||||
#| msgid "Now"
|
||||
msgid "Nov"
|
||||
msgstr "Ahora"
|
||||
msgstr "Nov"
|
||||
|
||||
#: templates/insights/fragments/month_by_month.html:105
|
||||
msgid "Dec"
|
||||
msgstr ""
|
||||
msgstr "Dic"
|
||||
|
||||
#: templates/insights/fragments/month_by_month.html:248
|
||||
#, fuzzy
|
||||
#| msgid "No transactions on this date"
|
||||
msgid "No transactions for this year"
|
||||
msgstr "No hay transacciones en esta fecha"
|
||||
msgstr "No hay transacciones en este año"
|
||||
|
||||
#: templates/insights/fragments/sankey.html:100
|
||||
msgid "From"
|
||||
@@ -3088,10 +3074,8 @@ msgid "Percentage"
|
||||
msgstr "Porcentaje"
|
||||
|
||||
#: templates/insights/fragments/year_by_year.html:202
|
||||
#, fuzzy
|
||||
#| msgid "transactions"
|
||||
msgid "No transactions"
|
||||
msgstr "transacciones"
|
||||
msgstr "No hay transacciones"
|
||||
|
||||
#: templates/insights/pages/index.html:37
|
||||
msgid "Month"
|
||||
@@ -3143,14 +3127,12 @@ msgid "Emergency Fund"
|
||||
msgstr "Fondo de Emergencia"
|
||||
|
||||
#: templates/insights/pages/index.html:127
|
||||
#, fuzzy
|
||||
#| msgid "Yearly by account"
|
||||
msgid "Year by Year"
|
||||
msgstr "Anual por cuenta"
|
||||
msgstr "Año por año"
|
||||
|
||||
#: templates/insights/pages/index.html:132
|
||||
msgid "Month by Month"
|
||||
msgstr ""
|
||||
msgstr "Mes por mes"
|
||||
|
||||
#: templates/installment_plans/fragments/add.html:5
|
||||
msgid "Add installment plan"
|
||||
@@ -3228,7 +3210,7 @@ msgstr "Ítem"
|
||||
#: templates/monthly_overview/fragments/list.html:15
|
||||
#: templates/transactions/fragments/list_all.html:15
|
||||
msgid "late"
|
||||
msgstr ""
|
||||
msgstr "Transacciones tardías"
|
||||
|
||||
#: templates/monthly_overview/fragments/list.html:58
|
||||
msgid "No transactions this month"
|
||||
|
||||
@@ -8,8 +8,8 @@ msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-01-10 20:50+0000\n"
|
||||
"PO-Revision-Date: 2025-10-07 20:17+0000\n"
|
||||
"Last-Translator: Erwan Colin <zephone@protonmail.com>\n"
|
||||
"PO-Revision-Date: 2026-01-14 22:24+0000\n"
|
||||
"Last-Translator: sorcierwax <freakywax@gmail.com>\n"
|
||||
"Language-Team: French <https://translations.herculino.com/projects/wygiwyh/"
|
||||
"app/fr/>\n"
|
||||
"Language: fr\n"
|
||||
@@ -17,11 +17,11 @@ msgstr ""
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=2; plural=n > 1;\n"
|
||||
"X-Generator: Weblate 5.13.3\n"
|
||||
"X-Generator: Weblate 5.15.1\n"
|
||||
|
||||
#: apps/accounts/forms.py:24
|
||||
msgid "Group name"
|
||||
msgstr "Nom de groupe"
|
||||
msgstr "Nom du groupe"
|
||||
|
||||
#: apps/accounts/forms.py:39 apps/accounts/forms.py:105
|
||||
#: apps/currencies/forms.py:53 apps/currencies/forms.py:87
|
||||
@@ -1097,7 +1097,7 @@ msgstr "Si..."
|
||||
|
||||
#: apps/rules/forms.py:53
|
||||
msgid "You can add actions to this rule in the next screen."
|
||||
msgstr ""
|
||||
msgstr "Vous pouvez ajouter des actions à cette règle dans le prochain écran."
|
||||
|
||||
#: apps/rules/forms.py:76
|
||||
msgid "Set field"
|
||||
@@ -1357,10 +1357,8 @@ msgid "Transaction Type"
|
||||
msgstr "Type de transaction"
|
||||
|
||||
#: apps/transactions/filters.py:89
|
||||
#, fuzzy
|
||||
#| msgid "Status"
|
||||
msgid "Mute Status"
|
||||
msgstr "Statut"
|
||||
msgstr "Ignorer le statut"
|
||||
|
||||
#: apps/transactions/filters.py:94
|
||||
msgid "Date from"
|
||||
@@ -2420,12 +2418,10 @@ msgstr "Tout désélectionner"
|
||||
|
||||
#: templates/cotton/ui/deleted_transactions_action_bar.html:46
|
||||
msgid "Invert election"
|
||||
msgstr ""
|
||||
msgstr "Inverser la sélection"
|
||||
|
||||
#: templates/cotton/ui/deleted_transactions_action_bar.html:59
|
||||
#: templates/cotton/ui/transactions_action_bar.html:84
|
||||
#, fuzzy
|
||||
#| msgid "Yes, delete them!"
|
||||
msgid "Yes, delete them!"
|
||||
msgstr "Oui, supprime !"
|
||||
|
||||
@@ -2443,8 +2439,6 @@ msgstr "Oui, supprime !"
|
||||
#: templates/cotton/ui/transactions_action_bar.html:191
|
||||
#: templates/cotton/ui/transactions_action_bar.html:207
|
||||
#: templates/cotton/ui/transactions_action_bar.html:223
|
||||
#, fuzzy
|
||||
#| msgid "copied!"
|
||||
msgid "copied!"
|
||||
msgstr "Copié !"
|
||||
|
||||
@@ -2479,10 +2473,8 @@ msgid "Count"
|
||||
msgstr "Compteur"
|
||||
|
||||
#: templates/cotton/ui/percentage_distribution.html:4
|
||||
#, fuzzy
|
||||
#| msgid "Income/Expense by Account"
|
||||
msgid "Income and Expense Percentages"
|
||||
msgstr "Revenu/Dépense par comptes"
|
||||
msgstr "Pourcentage de revenus et dépenses"
|
||||
|
||||
#: templates/cotton/ui/quick_transactions_buttons.html:25
|
||||
#: templates/cotton/ui/transactions_fab.html:27
|
||||
@@ -2501,7 +2493,7 @@ msgstr "Balance"
|
||||
|
||||
#: templates/cotton/ui/transactions_action_bar.html:46
|
||||
msgid "Invert selection"
|
||||
msgstr ""
|
||||
msgstr "Inverser la sélection"
|
||||
|
||||
#: templates/cotton/ui/transactions_action_bar.html:66
|
||||
msgid "Mark as unpaid"
|
||||
@@ -2711,8 +2703,8 @@ msgstr "Dernière récupération"
|
||||
#, python-format
|
||||
msgid "%(counter)s consecutive failure"
|
||||
msgid_plural "%(counter)s consecutive failures"
|
||||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
msgstr[0] "%(counter)s erreur consécutive"
|
||||
msgstr[1] "%(counter)s erreurs consécutives"
|
||||
|
||||
#: templates/exchange_rates_services/fragments/list.html:69
|
||||
msgid "currencies"
|
||||
@@ -2823,7 +2815,7 @@ msgstr "Activer la navigation"
|
||||
|
||||
#: templates/includes/navbar/user_menu.html:4
|
||||
msgid "Toggle theme"
|
||||
msgstr ""
|
||||
msgstr "Basculer le thème"
|
||||
|
||||
#: templates/includes/navbar/user_menu.html:31
|
||||
msgid "Settings"
|
||||
@@ -3031,72 +3023,60 @@ msgstr "Aucunes transactions récentes"
|
||||
|
||||
#: templates/insights/fragments/month_by_month.html:86
|
||||
#: templates/insights/fragments/year_by_year.html:54
|
||||
#, fuzzy
|
||||
#| msgid "Tags"
|
||||
msgid "Tag"
|
||||
msgstr "Etiquettes"
|
||||
msgstr "Etiquette"
|
||||
|
||||
#: templates/insights/fragments/month_by_month.html:94
|
||||
msgid "Jan"
|
||||
msgstr ""
|
||||
msgstr "Jan"
|
||||
|
||||
#: templates/insights/fragments/month_by_month.html:95
|
||||
msgid "Feb"
|
||||
msgstr ""
|
||||
msgstr "Fév"
|
||||
|
||||
#: templates/insights/fragments/month_by_month.html:96
|
||||
#, fuzzy
|
||||
#| msgid "Max"
|
||||
msgid "Mar"
|
||||
msgstr "Max"
|
||||
msgstr "Mar"
|
||||
|
||||
#: templates/insights/fragments/month_by_month.html:97
|
||||
msgid "Apr"
|
||||
msgstr ""
|
||||
msgstr "Avr"
|
||||
|
||||
#: templates/insights/fragments/month_by_month.html:98
|
||||
#, fuzzy
|
||||
#| msgid "Max"
|
||||
msgid "May"
|
||||
msgstr "Max"
|
||||
msgstr "Mai"
|
||||
|
||||
#: templates/insights/fragments/month_by_month.html:99
|
||||
msgid "Jun"
|
||||
msgstr ""
|
||||
msgstr "Juin"
|
||||
|
||||
#: templates/insights/fragments/month_by_month.html:100
|
||||
msgid "Jul"
|
||||
msgstr ""
|
||||
msgstr "Jui"
|
||||
|
||||
#: templates/insights/fragments/month_by_month.html:101
|
||||
msgid "Aug"
|
||||
msgstr ""
|
||||
msgstr "Aou"
|
||||
|
||||
#: templates/insights/fragments/month_by_month.html:102
|
||||
#, fuzzy
|
||||
#| msgid "Set"
|
||||
msgid "Sep"
|
||||
msgstr "Définir"
|
||||
msgstr "Sep"
|
||||
|
||||
#: templates/insights/fragments/month_by_month.html:103
|
||||
msgid "Oct"
|
||||
msgstr ""
|
||||
msgstr "Oct"
|
||||
|
||||
#: templates/insights/fragments/month_by_month.html:104
|
||||
#, fuzzy
|
||||
#| msgid "Now"
|
||||
msgid "Nov"
|
||||
msgstr "Maintenant"
|
||||
msgstr "Nov"
|
||||
|
||||
#: templates/insights/fragments/month_by_month.html:105
|
||||
msgid "Dec"
|
||||
msgstr ""
|
||||
msgstr "Déc"
|
||||
|
||||
#: templates/insights/fragments/month_by_month.html:248
|
||||
#, fuzzy
|
||||
#| msgid "No transactions on this date"
|
||||
msgid "No transactions for this year"
|
||||
msgstr "Aucunes transactions à cette date"
|
||||
msgstr "Aucunes transactions pour cette année"
|
||||
|
||||
#: templates/insights/fragments/sankey.html:100
|
||||
msgid "From"
|
||||
@@ -3107,10 +3087,8 @@ msgid "Percentage"
|
||||
msgstr "Pourcentage"
|
||||
|
||||
#: templates/insights/fragments/year_by_year.html:202
|
||||
#, fuzzy
|
||||
#| msgid "transactions"
|
||||
msgid "No transactions"
|
||||
msgstr "transactions"
|
||||
msgstr "Pas de transactions"
|
||||
|
||||
#: templates/insights/pages/index.html:37
|
||||
msgid "Month"
|
||||
@@ -3162,14 +3140,12 @@ msgid "Emergency Fund"
|
||||
msgstr "Fonds de secours"
|
||||
|
||||
#: templates/insights/pages/index.html:127
|
||||
#, fuzzy
|
||||
#| msgid "Yearly by account"
|
||||
msgid "Year by Year"
|
||||
msgstr "Annuel par comptes"
|
||||
msgstr "Année par année"
|
||||
|
||||
#: templates/insights/pages/index.html:132
|
||||
msgid "Month by Month"
|
||||
msgstr ""
|
||||
msgstr "Mois par mois"
|
||||
|
||||
#: templates/installment_plans/fragments/add.html:5
|
||||
msgid "Add installment plan"
|
||||
@@ -3249,7 +3225,7 @@ msgstr "Eléments"
|
||||
#: templates/monthly_overview/fragments/list.html:15
|
||||
#: templates/transactions/fragments/list_all.html:15
|
||||
msgid "late"
|
||||
msgstr ""
|
||||
msgstr "En retard"
|
||||
|
||||
#: templates/monthly_overview/fragments/list.html:58
|
||||
msgid "No transactions this month"
|
||||
@@ -3515,10 +3491,8 @@ msgid "This rule has no actions"
|
||||
msgstr "Cette règle n'a pas d'actions"
|
||||
|
||||
#: templates/rules/fragments/transaction_rule/view.html:140
|
||||
#, fuzzy
|
||||
#| msgid "Add notes to transactions"
|
||||
msgid "Add new action"
|
||||
msgstr "Ajouter des notes aux transactions"
|
||||
msgstr "Ajouter une nouvelle action"
|
||||
|
||||
#: templates/rules/fragments/transaction_rule/view.html:145
|
||||
msgid "Edit Transaction"
|
||||
|
||||
3560
app/locale/ru/LC_MESSAGES/django.po
Normal file
3560
app/locale/ru/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load Diff
3545
app/locale/sw/LC_MESSAGES/django.po
Normal file
3545
app/locale/sw/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load Diff
6
uv.lock
generated
6
uv.lock
generated
@@ -1151,11 +1151,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "urllib3"
|
||||
version = "2.6.2"
|
||||
version = "2.6.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1e/24/a2a2ed9addd907787d7aa0355ba36a6cadf1768b934c652ea78acbd59dcd/urllib3-2.6.2.tar.gz", hash = "sha256:016f9c98bb7e98085cb2b4b17b87d2c702975664e4f060c6532e64d1c1a5e797", size = 432930, upload-time = "2025-12-11T15:56:40.252Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/b9/4095b668ea3678bf6a0af005527f39de12fb026516fb3df17495a733b7f8/urllib3-2.6.2-py3-none-any.whl", hash = "sha256:ec21cddfe7724fc7cb4ba4bea7aa8e2ef36f607a4bab81aa6ce42a13dc3f03dd", size = 131182, upload-time = "2025-12-11T15:56:38.584Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Reference in New Issue
Block a user