from __future__ import annotations from collections.abc import Iterable from dataclasses import dataclass from datetime import date from decimal import Decimal, InvalidOperation from pathlib import Path 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", "users_summary_sheet": "Users Summary", "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", "working_hours": "Working hours", "non_working_hours": "Non-working hours", "hourly_rates": "Hourly rates", "project_percentages": "Project percentages", "client_percentages": "Client percentages", "tag_percentages": "Tag percentages", "summary_by_user": "Summary by user", "rate_history": "Hourly rate history", "from": "From", "to": "To", "now": "Now", "project": "Project", "percentage": "Percentage", "hour_percentage": "Hour %", "income_percentage": "Income %", "multiple_rates": "Multiple rates - see details", "variable_rate": "Variable rate", "none": "None", "daily_summary": "Daily Summary", "clients": "Clients", "projects": "Projects", "tags": "Tags", "date": "Date", "name": "Name", "total": "Total", "no_data": "No data", "uncategorized_client": "No client", "uncategorized_project": "No project", "uncategorized_tag": "No tag", }, "fa": { "report_title": "گزارش فضای کاری", "overall_sheet": "گزارش کلی", "users_summary_sheet": "خلاصه کاربران", "workspace": "فضای کاری", "period": "بازه", "from_date": "از تاریخ", "to_date": "تا تاریخ", "user": "کاربر", "mobile": "موبایل", "all_users": "همه کاربران", "generated_at": "تاریخ تولید", "summary": "خلاصه", "total_hours": "کل ساعات", "billable_hours": "ساعات کاری", "non_billable_hours": "ساعات غیر کاری", "hourly_rate": "نرخ ساعتی", "income": "کارکرد", "working_hours": "ساعات کاری", "non_working_hours": "ساعات غیرکاری", "hourly_rates": "نرخ‌های ساعتی", "project_percentages": "درصد پروژه‌ها", "client_percentages": "درصد مشتری‌ها", "tag_percentages": "درصد تگ‌ها", "summary_by_user": "خلاصه کاربران", "rate_history": "تاریخچه نرخ ساعتی", "from": "از", "to": "تا", "now": "حال", "project": "پروژه", "percentage": "درصد", "hour_percentage": "درصد ساعت", "income_percentage": "درصد کارکرد", "multiple_rates": "چند نرخ - جزئیات در گزارش کاربر", "variable_rate": "نرخ متغیر", "none": "بدون مورد", "daily_summary": "خلاصه روزانه", "clients": "مشتریان", "projects": "پروژه‌ها", "tags": "تگ‌ها", "date": "تاریخ", "name": "نام", "total": "جمع", "no_data": "بدون داده", "uncategorized_client": "بدون مشتری", "uncategorized_project": "بدون پروژه", "uncategorized_tag": "بدون تگ", }, } 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": "لیر"}, } DECIMAL_TRIM_CURRENCIES = {"IRR", "IRT"} @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: return self.format_amount_for_currency(value, None, ascii_digits=ascii_digits) def format_amount_for_currency( self, value: object, currency: str | None, *, 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 = ( "" if str(currency or "").upper() in DECIMAL_TRIM_CURRENCIES else 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_for_currency(item['amount'], item['currency'], 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