from __future__ import annotations from dataclasses import dataclass from datetime import date 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", "all_users": "All users", "generated_at": "Generated at", "summary": "Summary", "total_hours": "Total hours", "billable_hours": "Billable hours", "non_billable_hours": "Non-billable hours", "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": "کاربر", "all_users": "همه کاربران", "generated_at": "تاریخ تولید", "summary": "خلاصه", "total_hours": "کل ساعات", "billable_hours": "ساعات کاری", "non_billable_hours": "ساعات غیر کاری", "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": "بازه دلخواه", }, } @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_money_label(self, income_totals: list[dict], *, ascii_digits: bool = False) -> str: if not income_totals: return "-" parts = [] for item in income_totals: parts.append(f"{self.format_number(item['amount'], ascii_digits=ascii_digits)} {item['currency']}") return " | ".join(parts) 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