from __future__ import annotations from dataclasses import dataclass from datetime import date from decimal import Decimal, InvalidOperation from pathlib import Path from typing import Iterable import jdatetime from arabic_reshaper import reshape from bidi.algorithm import get_display PERSIAN_DIGITS = str.maketrans("0123456789", "۰۱۲۳۴۵۶۷۸۹") ARABIC_RANGES = ( (0x0600, 0x06FF), (0x0750, 0x077F), (0x08A0, 0x08FF), (0xFB50, 0xFDFF), (0xFE70, 0xFEFF), ) TRANSLATIONS = { "en": { "report_title": "Workspace Report", "overall_sheet": "Overall Report", "workspace": "Workspace", "period": "Period", "from_date": "From date", "to_date": "To date", "user": "User", "mobile": "Mobile", "all_users": "All users", "generated_at": "Generated at", "summary": "Summary", "total_hours": "Total hours", "billable_hours": "Billable hours", "non_billable_hours": "Non-billable hours", "hourly_rate": "Hourly rate", "income": "Income", "daily_summary": "Daily Summary", "clients": "Clients", "projects": "Projects", "tags": "Tags", "date": "Date", "name": "Name", "total": "Total", "no_data": "No data", }, "fa": { "report_title": "گزارش فضای کاری", "overall_sheet": "گزارش کلی", "workspace": "فضای کاری", "period": "بازه", "from_date": "از تاریخ", "to_date": "تا تاریخ", "user": "کاربر", "mobile": "موبایل", "all_users": "همه کاربران", "generated_at": "تاریخ تولید", "summary": "خلاصه", "total_hours": "کل ساعات", "billable_hours": "ساعات کاری", "non_billable_hours": "ساعات غیر کاری", "hourly_rate": "نرخ ساعتی", "income": "درآمد", "daily_summary": "خلاصه روزانه", "clients": "مشتریان", "projects": "پروژه‌ها", "tags": "تگ‌ها", "date": "تاریخ", "name": "نام", "total": "جمع", "no_data": "بدون داده", }, } PERIOD_LABELS = { "en": { "this_week": "This week", "this_month": "This month", "this_year": "This year", "half_year_first": "First half of year", "half_year_second": "Second half of year", "period": "Custom period", }, "fa": { "this_week": "این هفته", "this_month": "این ماه", "this_year": "امسال", "half_year_first": "نیمه اول سال", "half_year_second": "نیمه دوم سال", "period": "بازه دلخواه", }, } CURRENCY_LABELS = { "USD": {"en": "USD", "fa": "دلار آمریکا"}, "EUR": {"en": "EUR", "fa": "یورو"}, "GBP": {"en": "GBP", "fa": "پوند"}, "IRR": {"en": "IRR", "fa": "ریال"}, "IRT": {"en": "IRT", "fa": "تومان"}, "AED": {"en": "AED", "fa": "درهم"}, "TRY": {"en": "TRY", "fa": "لیر"}, } @dataclass(frozen=True) class ExportLocale: language: str is_rtl: bool font_regular: str font_bold: str def t(self, key: str) -> str: return TRANSLATIONS[self.language][key] def period_label(self, period: str) -> str: return PERIOD_LABELS[self.language].get(period, period) def format_number(self, value: object, *, ascii_digits: bool = False) -> str: text = str(value) if self.language == "fa" and not ascii_digits: return text.translate(PERSIAN_DIGITS) return text def format_date(self, value: date | str | None, *, ascii_digits: bool = False) -> str: if value is None: return "-" if isinstance(value, str): value = date.fromisoformat(value) if self.language == "fa": jalali = jdatetime.date.fromgregorian(date=value) return self.format_number(jalali.strftime("%Y/%m/%d"), ascii_digits=ascii_digits) return value.strftime("%Y/%m/%d") def format_duration(self, value: str, *, ascii_digits: bool = False) -> str: return self.format_number(value, ascii_digits=ascii_digits) def format_amount(self, value: object, *, ascii_digits: bool = False) -> str: raw = str(value).strip() if not raw: return raw try: decimal_value = Decimal(raw) except InvalidOperation: return self.format_number(raw, ascii_digits=ascii_digits) sign = "-" if decimal_value < 0 else "" unsigned = abs(decimal_value) normalized = format(unsigned, "f") integer_part, _, fractional_part = normalized.partition(".") grouped_integer = f"{int(integer_part):,}" formatted = f"{sign}{grouped_integer}" if fractional_part: trimmed_fraction = fractional_part.rstrip("0") if trimmed_fraction: formatted = f"{formatted}.{trimmed_fraction}" return self.format_number(formatted, ascii_digits=ascii_digits) def format_money_label(self, income_totals: list[dict], *, ascii_digits: bool = False) -> str: if not income_totals: return "-" parts = [] for item in income_totals: currency = self.currency_label(item["currency"]) parts.append(f"{self.format_amount(item['amount'], ascii_digits=ascii_digits)} {currency}") return " | ".join(parts) def currency_label(self, code: str | None) -> str: raw = str(code or "").upper() return CURRENCY_LABELS.get(raw, {}).get(self.language, raw) def shape(self, text: object) -> str: raw = str(text) if not any(start <= ord(char) <= end for char in raw for start, end in ARABIC_RANGES): return raw return get_display(reshape(raw)) def build_export_locale(language: str | None) -> ExportLocale: resolved = language if language in {"en", "fa"} else "en" assets_dir = Path(__file__).resolve().parent.parent / "assets" / "fonts" return ExportLocale( language=resolved, is_rtl=resolved == "fa", font_regular=str(assets_dir / "Vazirmatn-Regular.ttf"), font_bold=str(assets_dir / "Vazirmatn-Bold.ttf"), ) def user_label(user_payload: dict | None, locale: ExportLocale, *, ascii_digits: bool = False) -> str: if not user_payload: return locale.t("all_users") mobile = locale.format_number(user_payload.get("mobile") or "", ascii_digits=ascii_digits) if mobile: return f"{user_payload['name']} - {mobile}" return str(user_payload["name"]) def safe_sheet_title(title: str, used: Iterable[str]) -> str: invalid = set('[]:*?/\\') sanitized = "".join("-" if char in invalid else char for char in title).strip() or "Sheet" base = sanitized[:31] used_set = set(used) if base not in used_set: return base index = 2 while True: suffix = f"-{index}" candidate = f"{sanitized[:31 - len(suffix)]}{suffix}" if candidate not in used_set: return candidate index += 1