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", "\u06f0\u06f1\u06f2\u06f3\u06f4\u06f5\u06f6\u06f7\u06f8\u06f9") 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", "percentage": "Percentage", "hour_percentage": "Hour %", "income_percentage": "Income %", "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": "\u06af\u0632\u0627\u0631\u0634 \u0641\u0636\u0627\u06cc \u06a9\u0627\u0631\u06cc", "overall_sheet": "\u06af\u0632\u0627\u0631\u0634 \u06a9\u0644\u06cc", "users_summary_sheet": "\u062e\u0644\u0627\u0635\u0647 \u06a9\u0627\u0631\u0628\u0631\u0627\u0646", "workspace": "\u0641\u0636\u0627\u06cc \u06a9\u0627\u0631\u06cc", "period": "\u0628\u0627\u0632\u0647", "from_date": "\u0627\u0632 \u062a\u0627\u0631\u06cc\u062e", "to_date": "\u062a\u0627 \u062a\u0627\u0631\u06cc\u062e", "user": "\u06a9\u0627\u0631\u0628\u0631", "mobile": "\u0645\u0648\u0628\u0627\u06cc\u0644", "all_users": "\u0647\u0645\u0647 \u06a9\u0627\u0631\u0628\u0631\u0627\u0646", "generated_at": "\u062a\u0627\u0631\u06cc\u062e \u062a\u0648\u0644\u06cc\u062f", "summary": "\u062e\u0644\u0627\u0635\u0647", "total_hours": "\u06a9\u0644 \u0633\u0627\u0639\u0627\u062a", "billable_hours": "\u0633\u0627\u0639\u0627\u062a \u06a9\u0627\u0631\u06cc", "non_billable_hours": "\u0633\u0627\u0639\u0627\u062a \u063a\u06cc\u0631 \u06a9\u0627\u0631\u06cc", "hourly_rate": "\u0646\u0631\u062e \u0633\u0627\u0639\u062a\u06cc", "income": "\u06a9\u0627\u0631\u06a9\u0631\u062f", "working_hours": "\u0633\u0627\u0639\u0627\u062a \u06a9\u0627\u0631\u06cc", "non_working_hours": "\u0633\u0627\u0639\u0627\u062a \u063a\u06cc\u0631\u06a9\u0627\u0631\u06cc", "hourly_rates": "\u0646\u0631\u062e\u200c\u0647\u0627\u06cc \u0633\u0627\u0639\u062a\u06cc", "project_percentages": "\u062f\u0631\u0635\u062f \u067e\u0631\u0648\u0698\u0647\u200c\u0647\u0627", "client_percentages": "\u062f\u0631\u0635\u062f \u0645\u0634\u062a\u0631\u06cc\u200c\u0647\u0627", "tag_percentages": "\u062f\u0631\u0635\u062f \u062a\u06af\u200c\u0647\u0627", "summary_by_user": "\u062e\u0644\u0627\u0635\u0647 \u06a9\u0627\u0631\u0628\u0631\u0627\u0646", "rate_history": "\u062a\u0627\u0631\u06cc\u062e\u0686\u0647 \u0646\u0631\u062e \u0633\u0627\u0639\u062a\u06cc", "from": "\u0627\u0632", "to": "\u062a\u0627", "percentage": "\u062f\u0631\u0635\u062f", "hour_percentage": "\u062f\u0631\u0635\u062f \u0633\u0627\u0639\u062a", "income_percentage": "\u062f\u0631\u0635\u062f \u06a9\u0627\u0631\u06a9\u0631\u062f", "none": "\u0628\u062f\u0648\u0646 \u0645\u0648\u0631\u062f", "daily_summary": "\u062e\u0644\u0627\u0635\u0647 \u0631\u0648\u0632\u0627\u0646\u0647", "clients": "\u0645\u0634\u062a\u0631\u06cc\u0627\u0646", "projects": "\u067e\u0631\u0648\u0698\u0647\u200c\u0647\u0627", "tags": "\u062a\u06af\u200c\u0647\u0627", "date": "\u062a\u0627\u0631\u06cc\u062e", "name": "\u0646\u0627\u0645", "total": "\u062c\u0645\u0639", "no_data": "\u0628\u062f\u0648\u0646 \u062f\u0627\u062f\u0647", "uncategorized_client": "\u0628\u062f\u0648\u0646 \u0645\u0634\u062a\u0631\u06cc", "uncategorized_project": "\u0628\u062f\u0648\u0646 \u067e\u0631\u0648\u0698\u0647", "uncategorized_tag": "\u0628\u062f\u0648\u0646 \u062a\u06af", }, } 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": "\u0627\u06cc\u0646 \u0647\u0641\u062a\u0647", "this_month": "\u0627\u06cc\u0646 \u0645\u0627\u0647", "this_year": "\u0627\u0645\u0633\u0627\u0644", "half_year_first": "\u0646\u06cc\u0645\u0647 \u0627\u0648\u0644 \u0633\u0627\u0644", "half_year_second": "\u0646\u06cc\u0645\u0647 \u062f\u0648\u0645 \u0633\u0627\u0644", "period": "\u0628\u0627\u0632\u0647 \u062f\u0644\u062e\u0648\u0627\u0647", }, } CURRENCY_LABELS = { "USD": {"en": "USD", "fa": "\u062f\u0644\u0627\u0631 \u0622\u0645\u0631\u06cc\u06a9\u0627"}, "EUR": {"en": "EUR", "fa": "\u06cc\u0648\u0631\u0648"}, "GBP": {"en": "GBP", "fa": "\u067e\u0648\u0646\u062f"}, "IRR": {"en": "IRR", "fa": "\u0631\u06cc\u0627\u0644"}, "IRT": {"en": "IRT", "fa": "\u062a\u0648\u0645\u0627\u0646"}, "AED": {"en": "AED", "fa": "\u062f\u0631\u0647\u0645"}, "TRY": {"en": "TRY", "fa": "\u0644\u06cc\u0631"}, } @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