From 92b237021f829855bd87bc46a4310594486877fb Mon Sep 17 00:00:00 2001 From: AmirHosseinZg Date: Fri, 5 Jun 2026 02:47:47 +0330 Subject: [PATCH 1/9] feat(i18n): add Persian (fa) backend locale and notification translations --- .../src/applications/notifications/i18n/fa.ts | 47 +++++++++++++++++++ .../applications/notifications/i18n/index.ts | 2 + backend/src/common/i18n.ts | 2 +- 3 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 backend/src/applications/notifications/i18n/fa.ts diff --git a/backend/src/applications/notifications/i18n/fa.ts b/backend/src/applications/notifications/i18n/fa.ts new file mode 100644 index 00000000..1c68b649 --- /dev/null +++ b/backend/src/applications/notifications/i18n/fa.ts @@ -0,0 +1,47 @@ +export const fa = { + 'If you no longer wish to receive notifications, change your preferences directly from your user space.': + 'اگر دیگر مایل به دریافت اعلان‌ها نیستید، تنظیمات خود را مستقیماً از فضای کاربری‌تان تغییر دهید.', + 'Access it from': 'از این طریق دسترسی پیدا کنید', + Comment: 'نظر', + commented: 'نظر داد', + 'You receive this notification if you are the owner of the file or if you have also commented on this file': + 'این اعلان را دریافت می‌کنید اگر مالک فایل هستید یا اگر در این فایل نیز نظر داده‌اید', + Space: 'فضا', + 'from the space': 'از فضا', + 'to the space': 'به فضا', + 'Access your spaces from': 'از این طریق به فضاهای خود دسترسی پیدا کنید', + 'Access this space from': 'از این طریق به این فضا دسترسی پیدا کنید', + 'You now have access to the space': 'اکنون به فضا دسترسی دارید', + 'You no longer have access to the space': 'دیگر به فضا دسترسی ندارید', + 'This space has been permanently deleted': 'این فضا برای همیشه حذف شده است', + anchored: 'پین کرد', + unanchored: 'از پین خارج کرد', + Share: 'اشتراک‌گذاری', + 'shared with you': 'با شما به اشتراک گذاشت', + 'no longer share with you': 'دیگر با شما به اشتراک نمی‌گذارد', + 'You now have access to the share': 'اکنون به اشتراک‌گذاری دسترسی دارید', + 'You no longer have access to the share': 'دیگر به اشتراک‌گذاری دسترسی ندارید', + 'You are no longer a member of the parent share, your child share has been deleted': + 'شما دیگر عضو اشتراک‌گذاری والد نیستید، اشتراک‌گذاری فرزند شما حذف شده است', + 'Access your shares from': 'از این طریق به اشتراک‌گذاری‌های خود دسترسی پیدا کنید', + 'Access password': 'رمز دسترسی', + Sync: 'همگام‌سازی', + 'Access your syncs from': 'از این طریق به همگام‌سازی‌های خود دسترسی پیدا کنید', + 'You are no longer synchronizing': 'دیگر همگام‌سازی نمی‌کنید', + 'Unlock Request': 'درخواست بازکردن قفل', + 'You receive this notification because you have a lock on this file.': 'این اعلان را دریافت می‌کنید زیرا قفل این فایل در اختیار شماست.', + 'sends you a request to unlock the file': 'از شما درخواست بازکردن قفل فایل را دارد', + 'Security notification': 'اعلان امنیتی', + 'Your account has been locked after several unsuccessful authentication attempts': 'حساب کاربری شما پس از چندین تلاش ناموفق احراز هویت قفل شده است', + 'This security notification concerns your Sync-in account. Please contact an administrator to perform the analysis and unlock your account.': + 'این اعلان امنیتی مربوط به حساب Sync-in شماست. لطفاً برای بررسی و بازکردن قفل حساب خود با مدیر سیستم تماس بگیرید.', + 'Two-factor authentication (2FA) on your account has been disabled': 'احراز هویت دو مرحله‌ای (2FA) حساب شما غیرفعال شد', + 'Two-factor authentication (2FA) on your account has been enabled': 'احراز هویت دو مرحله‌ای (2FA) حساب شما فعال شد', + 'You received this notification because the security of your Sync-in account has changed. If you think this was a mistake, please review your security settings or contact your administrator.': + 'این اعلان را دریافت کردید زیرا امنیت حساب Sync-in شما تغییر کرده است. اگر فکر می‌کنید اشتباهی رخ داده، لطفاً تنظیمات امنیتی خود را بررسی کنید یا با مدیر سیستم تماس بگیرید.', + 'Address IP': 'آدرس IP', + Browser: 'مرورگر', + 'New Version Available': 'نسخه جدید در دسترس است', + 'You receive this notification because you are the administrator of this server.': 'این اعلان را دریافت می‌کنید زیرا مدیر این سرور هستید.', + 'A new update is available': 'یک به‌روزرسانی جدید در دسترس است' +} as const diff --git a/backend/src/applications/notifications/i18n/index.ts b/backend/src/applications/notifications/i18n/index.ts index 1a102867..f2f154da 100644 --- a/backend/src/applications/notifications/i18n/index.ts +++ b/backend/src/applications/notifications/i18n/index.ts @@ -1,6 +1,7 @@ import { i18nLocale } from '../../../common/i18n' import { de } from './de' import { es } from './es' +import { fa } from './fa' import { fr } from './fr' import { hi } from './hi' import { it } from './it' @@ -17,6 +18,7 @@ import { zh } from './zh' export const translations = new Map>([ ['de', de], ['es', es], + ['fa', fa], ['fr', fr], ['hi', hi], ['it', it], diff --git a/backend/src/common/i18n.ts b/backend/src/common/i18n.ts index 68a9f53e..f5bf6a13 100644 --- a/backend/src/common/i18n.ts +++ b/backend/src/common/i18n.ts @@ -1,5 +1,5 @@ export const LANG_DEFAULT = 'en' as const -export const LANG_SUPPORTED = new Set(['de', 'en', 'es', 'fr', 'hi', 'it', 'ja', 'ko', 'nl', 'pl', 'pt', 'pt-BR', 'ru', 'tr', 'zh'] as const) +export const LANG_SUPPORTED = new Set(['de', 'en', 'es', 'fa', 'fr', 'hi', 'it', 'ja', 'ko', 'nl', 'pl', 'pt', 'pt-BR', 'ru', 'tr', 'zh'] as const) export type i18nLocaleSupported = typeof LANG_SUPPORTED extends Set ? T : never export type i18nLocale = Exclude From 34233814143688921a008c87c4efc43519ebd756 Mon Sep 17 00:00:00 2001 From: AmirHosseinZg Date: Fri, 5 Jun 2026 02:48:01 +0330 Subject: [PATCH 2/9] feat(i18n): add Persian frontend translations, dayjs and bootstrap locales --- frontend/src/i18n/fa.json | 712 ++++++++++++++++++++++++++++ frontend/src/i18n/l10n.ts | 3 +- frontend/src/i18n/lib/bs.i18n.ts | 2 + frontend/src/i18n/lib/dayjs.i18n.ts | 1 + 4 files changed, 717 insertions(+), 1 deletion(-) create mode 100644 frontend/src/i18n/fa.json diff --git a/frontend/src/i18n/fa.json b/frontend/src/i18n/fa.json new file mode 100644 index 00000000..a6832119 --- /dev/null +++ b/frontend/src/i18n/fa.json @@ -0,0 +1,712 @@ +{ + "Sign-in to your account": "به حساب کاربری خود وارد شوید", + "Wrong login or password": "نام کاربری یا رمز عبور اشتباه است", + "Authentication service error": "خطای سرویس احراز هویت", + "Account locked": "حساب کاربری قفل شده است", + "Account is not allowed": "حساب کاربری مجاز نیست", + "Not authorized": "دسترسی غیرمجاز", + "Account matching error": "خطای تطبیق حساب کاربری", + "Authentication": "احراز هویت", + "Sign in": "ورود", + "Login": "نام کاربری", + "Password": "رمز عبور", + "Login or Email": "نام کاربری یا ایمیل", + "Login already used": "این نام کاربری قبلاً استفاده شده است", + "You are already logged in": "شما قبلاً وارد شده‌اید", + "Name already used": "این نام قبلاً استفاده شده است", + "Token has expired": "توکن منقضی شده است", + "Confirm": "تأیید", + "Cancel": "انصراف", + "Close": "بستن", + "Open": "باز کردن", + "Server connection error": "خطای اتصال به سرور", + "Filter": "فیلتر", + "Filters": "فیلترها", + "search": "جستجو", + "Search": "جستجو", + "Search for content": "جستجو در محتوا", + "Search for files": "جستجوی فایل‌ها", + "Full-text search": "جستجوی تمام متن", + "Full-text search is disabled": "جستجوی تمام متن غیرفعال است", + "Type to search for users or groups to add": "برای جستجوی کاربران یا گروه‌ها جهت افزودن تایپ کنید", + "Type to search for groups to add": "برای جستجوی گروه‌ها جهت افزودن تایپ کنید", + "Type to search for users to add": "برای جستجوی کاربران جهت افزودن تایپ کنید", + "Type to search for managers to add": "برای جستجوی مدیران جهت افزودن تایپ کنید", + "Member since": "عضو از تاریخ", + "Windows Manager": "مدیر پنجره‌ها", + "Mark as read": "علامت‌گذاری به عنوان خوانده شده", + "See": "مشاهده", + "no_task": "بدون وظیفه", + "one_task": "{{ nb }} وظیفه انجام شده", + "nb_tasks": "{{ nb }} وظیفه انجام شده", + "one_active_task": "{{ nba }} وظیفه در حال انجام از {{ nb }}", + "nb_active_tasks": "{{ nba }} وظیفه در حال انجام از {{ nb }}", + "nb_elements": "{{nb}} عنصر", + "nb_actions": "{{nb}} اقدام", + "one_message": "{{ nb }} پیام جدید", + "nb_messages": "{{ nb }} پیام جدید", + "one_notification": "{{ nb }} اعلان خوانده نشده", + "nb_notifications": "{{ nb }} اعلان خوانده نشده", + "Application": "برنامه", + "application": "برنامه", + "application and email": "برنامه و ایمیل", + "Notification": "اعلان", + "Notifications": "اعلان‌ها", + "Event": "رویداد", + "File": "فایل", + "Files": "فایل‌ها", + "Personal files": "فایل‌های شخصی", + "Personal": "شخصی", + "External": "خارجی", + "Share status": "وضعیت اشتراک‌گذاری", + "No recent files": "فایل اخیری وجود ندارد", + "Groups": "گروه‌ها", + "Group": "گروه", + "group": "گروه", + "New personal group": "گروه شخصی جدید", + "Edit personal group": "ویرایش گروه شخصی", + "personal group": "گروه شخصی", + "Personal group": "گروه شخصی", + "Personal groups": "گروه‌های شخصی", + "groups": "گروه‌ها", + "Parent group": "گروه والد", + "Group updated": "گروه به‌روزرسانی شد", + "Group created": "گروه ایجاد شد", + "Group not found": "گروه یافت نشد", + "Group error": "خطای گروه", + "Set as group manager": "تنظیم به عنوان مدیر گروه", + "Guest cannot be a group manager": "مهمان نمی‌تواند مدیر گروه باشد", + "As a manager, the user will be able to manage the group and its members.": "به عنوان مدیر، کاربر قادر به مدیریت گروه و اعضای آن خواهد بود.", + "As a manager, the user will be able to manage group members but not group properties.": "به عنوان مدیر، کاربر قادر به مدیریت اعضای گروه خواهد بود اما نه ویژگی‌های گروه.", + "Visible": "قابل مشاهده", + "Private": "خصوصی", + "Isolated": "ایزوله", + "Select the parent group": "گروه والد را انتخاب کنید", + "from the group": "از گروه", + "New group": "گروه جدید", + "Edit group": "ویرایش گروه", + "Leave group": "ترک گروه", + "Delete group": "حذف گروه", + "Remove from group": "حذف از گروه", + "Group must have at least one manager": "گروه باید حداقل یک مدیر داشته باشد", + "The group was left": "گروه ترک شد", + "The group was not left": "گروه ترک نشد", + "will be left, you will no longer be a member of this group": "ترک خواهد شد، شما دیگر عضو این گروه نخواهید بود", + "All users can see this group.": "همه کاربران می‌توانند این گروه را ببینند.", + "Users who are not members of this group cannot see it.": "کاربرانی که عضو این گروه نیستند نمی‌توانند آن را ببینند.", + "The group is not visible, its members cannot see it and cannot see each other.": "گروه قابل مشاهده نیست، اعضای آن نمی‌توانند آن را ببینند و یکدیگر را نیز نمی‌توانند ببینند.", + "Add": "افزودن", + "Add members": "افزودن اعضا", + "See members": "مشاهده اعضا", + "Administrator": "مدیر سیستم", + "Administration": "مدیریت", + "Tools": "ابزارها", + "User": "کاربر", + "user": "کاربر", + "Users": "کاربران", + "users": "کاربران", + "New user": "کاربر جدید", + "account": "حساب", + "Account": "حساب کاربری", + "Account status": "وضعیت حساب", + "Email": "ایمیل", + "Email already used": "این ایمیل قبلاً استفاده شده است", + "First name": "نام", + "Last name": "نام خانوادگی", + "Full name": "نام کامل", + "Status": "وضعیت", + "Role": "نقش", + "IP": "IP", + "IP Addresses": "نشانی‌های IP", + "Seen": "دیده شده", + "Connection": "اتصال", + "Connections": "اتصالات", + "Confirm deletion": "تأیید حذف", + "Confirm permanent deletion of data": "تأیید حذف دائمی داده‌ها", + "Space and data will be deleted in": "فضا و داده‌ها حذف خواهند شد در", + "Space is disabled": "فضا غیرفعال است", + "Storage Space": "فضای ذخیره‌سازی", + "Storage Quota": "سهمیه ذخیره‌سازی", + "Storage Usage": "مصرف ذخیره‌سازی", + "Space status": "وضعیت فضا", + "Storage quota exceeded": "سهمیه ذخیره‌سازی پر شده است", + "File size limit exceeded": "محدودیت اندازه فایل رد شده است", + "The trash is read-only": "سطل زباله فقط خواندنی است", + "Storage quota will be exceeded": "سهمیه ذخیره‌سازی پر خواهد شد", + "Access to internal IP addresses is forbidden": "دسترسی به نشانی‌های IP داخلی ممنوع است", + "Missing or invalid \"content-length\" header": "سرآیند «content-length» نامعتبر یا موجود نیست", + "Maximum redirects exceeded": "حداکثر تعداد تغییر مسیر رد شده است", + "Missing redirect location": "مقصد تغییر مسیر مشخص نشده است", + "Unsafe redirect location": "مقصد تغییر مسیر ناامن", + "No more space available": "فضای بیشتری در دسترس نیست", + "available_space_is_low": "فضای در دسترس کم است ( {{ nb }}% )", + "online": "آنلاین", + "Online users": "کاربران آنلاین", + "Unlimited": "نامحدود", + "member": "عضو", + "members": "اعضا", + "Member": "عضو", + "Members": "اعضا", + "Manager": "مدیر", + "manager": "مدیر", + "Managers": "مدیران", + "At least one manager is required": "حداقل یک مدیر الزامی است", + "Generate": "تولید", + "Import": "وارد کردن", + "Configuration": "پیکربندی", + "Applications": "برنامه‌ها", + "applications": "برنامه‌ها", + "Permissions inherited from groups": "مجوزهای به ارث رسیده از گروه‌ها", + "Permissions inherited from the file": "مجوزهای به ارث رسیده از فایل", + "Only the group will be deleted, the members will no longer be part of it.": "فقط گروه حذف خواهد شد، اعضا دیگر بخشی از آن نخواهند بود.", + "Avatar": "آواتار", + "Update": "به‌روزرسانی", + "current password": "رمز عبور فعلی", + "new password": "رمز عبور جدید", + "Change me !": "مرا تغییر دهید!", + "Current password missing !": "رمز عبور فعلی وارد نشده است!", + "New password missing !": "رمز عبور جدید وارد نشده است!", + "New password must have 8 characters minimum": "رمز عبور جدید باید حداقل ۸ کاراکتر داشته باشد", + "Current password does not match": "رمز عبور فعلی مطابقت ندارد", + "Password has been updated": "رمز عبور به‌روزرسانی شد", + "Unable to update password": "امکان به‌روزرسانی رمز عبور وجود ندارد", + "Bad password": "رمز عبور نادرست", + "too many login attempts": "تلاش‌های ورود بیش از حد مجاز", + "Language": "زبان", + "Language updated": "زبان به‌روزرسانی شد", + "Unable to update language": "امکان به‌روزرسانی زبان وجود ندارد", + "Notification preference updated": "تنظیمات اعلان به‌روزرسانی شد", + "Full-text search preference updated": "تنظیمات جستجوی تمام متن به‌روزرسانی شد", + "Unable to update notification preference": "امکان به‌روزرسانی تنظیمات اعلان وجود ندارد", + "Unable to update full-text search preference": "امکان به‌روزرسانی تنظیمات جستجوی تمام متن وجود ندارد", + "Unable to open document": "امکان باز کردن سند وجود ندارد", + "Unable to save document": "امکان ذخیره سند وجود ندارد", + "Home": "خانه", + "Recents": "اخیراً استفاده شده", + "recents": "اخیراً", + "Trash": "سطل زباله", + "trash bins": "سطل‌های زباله", + "Shares": "اشتراک‌گذاری‌ها", + "Share": "اشتراک‌گذاری", + "shares": "اشتراک‌گذاری‌ها", + "shared": "به اشتراک گذاشته شده", + "personal_space": "دسترسی به فضای شخصی", + "spaces_access": "دسترسی به فضاها", + "spaces_admin": "مدیریت فضاها", + "shares_access": "دسترسی به اشتراک‌گذاری‌ها", + "shares_admin": "مدیریت اشتراک‌گذاری‌ها", + "guests_admin": "مدیریت مهمانان", + "personal_groups_admin": "مدیریت گروه‌های شخصی", + "desktop_app_access": "دسترسی از برنامه دسکتاپ", + "desktop_app_sync": "همگام‌سازی از برنامه دسکتاپ", + "webdav_access": "دسترسی WebDAV", + "Create share": "ایجاد اشتراک‌گذاری", + "Edit share": "ویرایش اشتراک‌گذاری", + "Delete share": "حذف اشتراک‌گذاری", + "Child shares": "اشتراک‌گذاری‌های فرزند", + "webdav": "WebDAV", + "Share is disabled": "اشتراک‌گذاری غیرفعال است", + "Space": "فضا", + "Spaces": "فضاها", + "spaces": "فضاها", + "Space name": "نام فضا", + "New space": "فضای جدید", + "Create a new space": "ایجاد یک فضای جدید", + "Create space": "ایجاد فضا", + "Edit space": "ویرایش فضا", + "Delete space": "حذف فضا", + "Create a new share": "ایجاد یک اشتراک‌گذاری جدید", + "Type to search for space to select": "برای جستجوی فضا جهت انتخاب تایپ کنید", + "Anchor to a space": "متصل کردن به یک فضا", + "Anchor files to a space": "متصل کردن فایل‌ها به یک فضا", + "You have been invited to join this space": "شما برای پیوستن به این فضا دعوت شده‌اید", + "You have been invited to join this share": "شما برای پیوستن به این اشتراک‌گذاری دعوت شده‌اید", + "Shared with others": "به اشتراک گذاشته شده با دیگران", + "With others": "با دیگران", + "Shared with me": "با من به اشتراک گذاشته شده", + "With me": "با من", + "Shared via links": "به اشتراک گذاشته شده از طریق پیوندها", + "Via links": "از طریق پیوندها", + "Shared from": "به اشتراک گذاشته شده از", + "share with you this": "این را با شما به اشتراک می‌گذارد", + "shared with you": "با شما به اشتراک گذاشته است", + "no longer share with you": "دیگر با شما به اشتراک نمی‌گذارد", + "You now have access to the space": "اکنون به فضا دسترسی دارید", + "You no longer have access to the space": "دیگر به فضا دسترسی ندارید", + "This space has been permanently deleted": "این فضا به طور دائمی حذف شده است", + "You now have access to the share": "اکنون به اشتراک‌گذاری دسترسی دارید", + "You no longer have access to the share": "دیگر به اشتراک‌گذاری دسترسی ندارید", + "You are no longer a member of the parent share, your child share has been deleted": "شما دیگر عضو اشتراک‌گذاری والد نیستید، اشتراک‌گذاری فرزند شما حذف شده است", + "anchored": "متصل شده", + "unanchored": "جدا شده", + "Space not found": "فضا یافت نشد", + "Share not found": "اشتراک‌گذاری یافت نشد", + "Missing information": "اطلاعات ناقص", + "Go to": "رفتن به", + "Location": "مکان", + "Name and location are required": "نام و مکان الزامی است", + "Parent location already exists in files": "مکان والد قبلاً در فایل‌ها وجود دارد", + "Check the location": "مکان را بررسی کنید", + "Location not found": "مکان یافت نشد", + "Forbidden resource": "منبع ممنوعه", + "Resource already exists": "منبع قبلاً وجود دارد", + "The location does not exist": "مکان وجود ندارد", + "The location is not readable": "مکان قابل خواندن نیست", + "The location is not writeable": "مکان قابل نوشتن نیست", + "The location is a directory": "مکان یک پوشه است", + "The file is locked": "فایل قفل شده است", + "This access is protected by a password": "این دسترسی با رمز عبور محافظت می‌شود", + "You do not have share permission": "شما مجوز اشتراک‌گذاری ندارید", + "Desktop app permission required": "مجوز برنامه دسکتاپ الزامی است", + "You can not share a space": "نمی‌توانید یک فضا را به اشتراک بگذارید", + "You can not remove an anchored file": "نمی‌توانید یک فایل متصل شده را حذف کنید", + "You are not allowed to write here": "اجازه نوشتن در اینجا را ندارید", + "You are not allowed to do this action": "اجازه انجام این عملیات را ندارید", + "You can not move an anchored file": "نمی‌توانید یک فایل متصل شده را جابجا کنید", + "You can not move a locked file": "نمی‌توانید یک فایل قفل شده را جابجا کنید", + "Anchored": "متصل شده", + "Anchored by": "متصل شده توسط", + "Manage my anchored files": "مدیریت فایل‌های متصل شده من", + "You have no files anchored on this space": "هیچ فایل متصل شده‌ای در این فضا ندارید", + "An anchored file already has this name": "یک فایل متصل شده قبلاً این نام را دارد", + "Settings": "تنظیمات", + "Options": "گزینه‌ها", + "Name": "نام", + "Download": "بارگیری", + "Download done": "بارگیری انجام شد", + "Download failed": "بارگیری ناموفق بود", + "Upload done": "بارگذاری انجام شد", + "Upload failed": "بارگذاری ناموفق بود", + "Copy · Move": "کپی · جابجایی", + "Move": "جابجایی", + "Move done": "جابجایی انجام شد", + "Move failed": "جابجایی ناموفق بود", + "Deletion done": "حذف انجام شد", + "Deletion failed": "حذف ناموفق بود", + "Creation failed": "ایجاد ناموفق بود", + "Copy": "کپی", + "Copy done": "کپی انجام شد", + "Copy failed": "کپی ناموفق بود", + "Copy-Paste": "کپی-چسباندن", + "Cut-Paste": "برش-چسباندن", + "Rename": "تغییر نام", + "Edit": "ویرایش", + "Share inside": "اشتراک‌گذاری داخلی", + "Share outside": "اشتراک‌گذاری خارجی", + "Remove": "حذف", + "Clipboard": "حافظه موقت", + "Send to Clipboard": "ارسال به حافظه موقت", + "will be removed": "حذف خواهد شد", + "Refresh": "بازخوانی", + "Empty": "خالی کردن", + "List": "لیست", + "Display": "نمایش", + "Sort by": "مرتب‌سازی بر اساس", + "trash_one_file": "آیا می‌خواهید {{arg}} را به سطل زباله منتقل کنید؟", + "delete_one_file": "آیا می‌خواهید {{arg}} را به طور دائمی حذف کنید؟", + "trash_multiple_files": "آیا می‌خواهید این {{arg}} فایل را به سطل زباله منتقل کنید؟", + "delete_multiple_files": "آیا می‌خواهید این {{arg}} فایل را به طور دائمی حذف کنید؟", + "Moving to trash": "در حال انتقال به سطل زباله", + "Permanent deletion": "حذف دائمی", + "Would you like to empty the trash ?": "آیا می‌خواهید سطل زباله را خالی کنید؟", + "All items will be permanently deleted": "همه موارد به طور دائمی حذف خواهند شد", + "Actions will be performed in the current folder": "عملیات در پوشه فعلی انجام خواهد شد", + "The client will no longer have permission to access your account and will no longer be able to synchronize.": "کلاینت دیگر مجوز دسترسی به حساب شما را نخواهد داشت و قادر به همگام‌سازی نخواهد بود.", + "The access expires": "دسترسی منقضی می‌شود", + "The access has expired": "دسترسی منقضی شده است", + "Never expires": "هرگز منقضی نمی‌شود", + "synchronization": "همگام‌سازی", + "Synchronization": "همگام‌سازی", + "Synchronizations": "همگام‌سازی‌ها", + "Synchronize": "همگام‌سازی", + "Sync": "همگام", + "Stop synchronization": "توقف همگام‌سازی", + "Synchronize all": "همگام‌سازی همه", + "Stop synchronizations": "توقف همگام‌سازی‌ها", + "Synced": "همگام‌سازی شده", + "Sync already exists": "همگام‌سازی قبلاً وجود دارد", + "Sync was reset": "همگام‌سازی بازنشانی شد", + "Sync deleted": "همگام‌سازی حذف شد", + "This client": "این کلاینت", + "You are no longer synchronizing": "شما دیگر همگام‌سازی نمی‌کنید", + "Compress": "فشرده‌سازی", + "Decompress": "خارج کردن از حالت فشرده", + "Compress and Download": "فشرده‌سازی و بارگیری", + "Compress and Save": "فشرده‌سازی و ذخیره", + "Enable compression": "فعال‌سازی فشرده‌سازی", + "Compression done": "فشرده‌سازی انجام شد", + "Compression failed": "فشرده‌سازی ناموفق بود", + "Decompression done": "خارج کردن از فشرده انجام شد", + "Decompression failed": "خارج کردن از فشرده ناموفق بود", + "(this may take longer)": "(این ممکن است بیشتر طول بکشد)", + "Save in the current directory": "ذخیره در پوشه فعلی", + "This name is already used": "این نام قبلاً استفاده شده است", + "Info": "اطلاعات", + "Size": "اندازه", + "Modified": "تغییر یافته", + "modified": "تغییر یافته", + "Tasks": "وظایف", + "Download from URL": "بارگیری از نشانی اینترنتی", + "URL (https://...)": "نشانی اینترنتی (https://...)", + "Upload files": "بارگذاری فایل‌ها", + "Upload folders": "بارگذاری پوشه‌ها", + "Folder name": "نام پوشه", + "New folder": "پوشه جدید", + "Document name": "نام سند", + "New document": "سند جدید", + "File name": "نام فایل", + "File permissions": "مجوزهای فایل", + "Document": "سند", + "Spreadsheet": "صفحه گسترده", + "Presentation": "ارائه", + "Text": "متن", + "Forbidden characters": "کاراکترهای غیرمجاز", + "Elements": "عناصر", + "Malformed URL": "نشانی اینترنتی نامعتبر", + "New": "جدید", + "Folder": "پوشه", + "folder": "پوشه", + "file": "فایل", + "files": "فایل‌ها", + "directory": "پوشه", + "directories": "پوشه‌ها", + "Total": "مجموع", + "no_selection": "هیچ موردی انتخاب نشده است", + "one_selection": "{{ nb }} مورد انتخاب شده است", + "nb_selections": "{{ nb }} مورد انتخاب شده است", + "Archive name": "نام بایگانی", + "Drag and drop your files here": "فایل‌های خود را اینجا بکشید و رها کنید", + "The destination already exists": "مقصد قبلاً وجود دارد", + "This item is already selected": "این مورد قبلاً انتخاب شده است", + "Parent item is already selected": "مورد والد قبلاً انتخاب شده است", + "This file contains binary data that can not be read": "این فایل حاوی داده‌های باینری است که قابل خواندن نیست", + "Navigation Tree": "درخت ناوبری", + "item": "مورد", + "items": "موارد", + "create": "ایجاد", + "write": "نوشتن", + "move": "جابجایی", + "delete": "حذف", + "modify": "تغییر", + "Delete user": "حذف کاربر", + "Delete all user files": "حذف همه فایل‌های کاربر", + "share": "اشتراک‌گذاری", + "Share name": "نام اشتراک‌گذاری", + "New share": "اشتراک‌گذاری جدید", + "New share link": "پیوند اشتراک‌گذاری جدید", + "New link": "پیوند جدید", + "Edit children shares": "ویرایش اشتراک‌گذاری‌های فرزند", + "Link": "پیوند", + "link": "پیوند", + "Links": "پیوندها", + "links": "پیوندها", + "Link copied": "پیوند کپی شد", + "Link created": "پیوند ایجاد شد", + "Link deleted": "پیوند حذف شد", + "Link error": "خطای پیوند", + "Copy link": "کپی پیوند", + "Edit link": "ویرایش پیوند", + "Guest Link": "پیوند مهمان", + "Guest name": "نام مهمان", + "Guest email": "ایمیل مهمان", + "Guest language": "زبان مهمان", + "Guest": "مهمان", + "Guests": "مهمانان", + "guest": "مهمان", + "guests": "مهمانان", + "New guest": "مهمان جدید", + "Edit guest": "ویرایش مهمان", + "Guest created": "مهمان ایجاد شد", + "Guest deleted": "مهمان حذف شد", + "Guest updated": "مهمان به‌روزرسانی شد", + "Guest error": "خطای مهمان", + "Add an external location": "افزودن مکان خارجی", + "External location": "مکان خارجی", + "Select a file": "انتخاب یک فایل", + "The link is expired": "پیوند منقضی شده است", + "The link is disabled": "پیوند غیرفعال است", + "The link was not found": "پیوند یافت نشد", + "Expired": "منقضی شده", + "expired": "منقضی شده", + "none": "هیچ", + "The maximum number of access allowed to the link is exceeded": "حداکثر تعداد دسترسی مجاز به پیوند رد شده است", + "Set a password": "تنظیم رمز عبور", + "Enter your password": "رمز عبور خود را وارد کنید", + "Permissions": "مجوزها", + "No permissions": "بدون مجوز", + "Owner": "مالک", + "Me": "من", + "Shared": "به اشتراک گذاشته شده", + "Created": "ایجاد شده", + "Created & Modified": "ایجاد و تغییر یافته", + "Creation date": "تاریخ ایجاد", + "Modification date": "تاریخ تغییر", + "Deactivation date": "تاریخ غیرفعال‌سازی", + "Date": "تاریخ", + "Path": "مسیر", + "active": "فعال", + "Active": "فعال", + "suspended": "معلق", + "Unable to rename user space": "امکان تغییر نام فضای کاربر وجود ندارد", + "Unable to delete user space": "امکان حذف فضای کاربر وجود ندارد", + "Unable to update user": "امکان به‌روزرسانی کاربر وجود ندارد", + "Unable to update user groups": "امکان به‌روزرسانی گروه‌های کاربر وجود ندارد", + "User created": "کاربر ایجاد شد", + "User updated": "کاربر به‌روزرسانی شد", + "User not found": "کاربر یافت نشد", + "Edit user": "ویرایش کاربر", + "Impersonate identity": "جعل هویت", + "Type": "نوع", + "Description": "توضیحات", + "Visibility": "قابلیت مشاهده", + "Unknown error !": "خطای ناشناخته!", + "Unknown": "ناشناخته", + "Expiration": "انقضا", + "Limit access": "محدود کردن دسترسی", + "Current access count": "تعداد دسترسی فعلی", + "Access": "دسترسی", + "Accessed": "دسترسی یافته", + "Last access": "آخرین دسترسی", + "Last accesses": "آخرین دسترسی‌ها", + "Comment": "نظر", + "Comments": "نظرات", + "comments": "نظرات", + "commented": "نظر داده است", + "No recent comments": "نظر اخیری وجود ندارد", + "Write a comment ...": "نوشتن نظر ...", + "yes": "بله", + "no": "خیر", + "Client": "کلاینت", + "Clients": "کلاینت‌ها", + "available": "در دسترس", + "busy": "مشغول", + "absent": "غایب", + "offline": "آفلاین", + "Session has expired": "نشست منقضی شده است", + "Please sign in": "لطفاً وارد شوید", + "logout": "خروج", + "days": "روزها", + "day": "روز", + "Delete": "حذف", + "Scheduler": "زمان‌بند", + "async": "ناهمگام", + "seq": "ترتیبی", + "Transfers": "انتقالات", + "Simulate": "شبیه‌سازی", + "Reset": "بازنشانی", + "Browse": "مرور", + "Action": "عملیات", + "Added": "افزوده شد", + "Removed": "حذف شد", + "Copied": "کپی شد", + "Moved": "جابجا شد", + "Filtered": "فیلتر شد", + "Error": "خطا", + "Server": "سرور", + "Direction": "جهت", + "Conflict": "تداخل", + "Show filtered files": "نمایش فایل‌های فیلتر شده", + "recent": "اخیر", + "Source": "مبدأ", + "Destination": "مقصد", + "Mode": "حالت", + "Sequentially": "ترتیبی", + "Asynchronously": "ناهمگام", + "fast": "سریع", + "secure": "امن", + "enabled": "فعال", + "Enable": "فعال‌سازی", + "disabled": "غیرفعال", + "Disable": "غیرفعال‌سازی", + "scheduler_unit_hour": "ساعت‌ها", + "scheduler_unit_minute": "دقیقه‌ها", + "scheduler_unit_day": "روزها", + "You must have permission to modify the server folder to choose a different sync mode": "برای انتخاب حالت همگام‌سازی متفاوت باید مجوز تغییر پوشه سرور را داشته باشید", + "upload only": "فقط بارگذاری", + "download only": "فقط بارگیری", + "both": "هر دو", + "never": "هرگز", + "Clear events": "پاک کردن رویدادها", + "Events from": "رویدادهای از", + "All syncs": "همه همگام‌سازی‌ها", + "All events": "همه رویدادها", + "will be cleared": "پاک خواهند شد", + "No differences": "بدون تفاوت", + "Select a folder": "انتخاب یک پوشه", + "The files containing": "فایل‌های حاوی", + "The files starting": "فایل‌هایی که شروع می‌شوند با", + "The files ending": "فایل‌هایی که پایان می‌یابند با", + "Expert (Regexp)": "پیشرفته (Regexp)", + "click on the browse button": "روی دکمه مرور کلیک کنید", + "with a name or pattern": "با یک نام یا الگو", + "with the extension ('.mp3', '.avi', '.mov' ...)": "با پسوند ('.mp3', '.avi', '.mov' ...)", + "Wizard": "راهنما", + "Next": "بعدی", + "Previous": "قبلی", + "Done": "انجام شد", + "This wizard will help you synchronize a directory on your computer with a Sync-in directory.": "این راهنما به شما کمک می‌کند یک پوشه در رایانه خود را با یک پوشه همگام‌سازی همگام کنید.", + "To begin, select a folder on your computer.": "برای شروع، یک پوشه در رایانه خود انتخاب کنید.", + "You can drag the folder into the area below or click on the \"Select\" button.": "می‌توانید پوشه را به ناحیه زیر بکشید یا روی دکمه «انتخاب» کلیک کنید.", + "Drop folder here": "پوشه را اینجا رها کنید", + "This directory is already synced": "این پوشه قبلاً همگام‌سازی شده است", + "The parent directory is already synced": "پوشه والد قبلاً همگام‌سازی شده است", + "This directory is not accessible": "این پوشه قابل دسترسی نیست", + "This directory is read-only, you will not be able to modify it": "این پوشه فقط خواندنی است، نمی‌توانید آن را تغییر دهید", + "Please select the server directory to sync, if it doesn't exist you can create it.": "لطفاً پوشه سرور را برای همگام‌سازی انتخاب کنید، اگر وجود ندارد می‌توانید آن را ایجاد کنید.", + "Double click to browse directories": "برای مرور پوشه‌ها دوبار کلیک کنید", + "The data will be synchronized from": "داده‌ها همگام‌سازی خواهند شد از", + "the client folder": "پوشه کلاینت", + "the server folder": "پوشه سرور", + "(One-Way)": "(یک‌طرفه)", + "(Two-Way)": "(دو‌طرفه)", + "to": "به", + "and from": "و از", + "All files created or modified in": "همه فایل‌های ایجاد یا تغییر یافته در", + "will be ignored and deleted": "نادیده گرفته و حذف خواهند شد", + "In case of conflict,": "در صورت تداخل،", + "the most recent files will be kept": "جدیدترین فایل‌ها نگه داشته خواهند شد", + "the client's files take precedence": "فایل‌های کلاینت اولویت دارند", + "the server's files take precedence": "فایل‌های سرور اولویت دارند", + "the files in": "فایل‌های موجود در", + "will be preferred": "ترجیح داده خواهند شد", + "Loading...": "در حال بارگذاری...", + "No results": "نتیجه‌ای یافت نشد", + "Download ARM64 version": "بارگیری نسخه ARM64", + "Download Apple Silicon ARM64 version": "بارگیری نسخه Apple Silicon ARM64", + "Download tar.gz version": "بارگیری نسخه tar.gz", + "Repository": "مخزن", + "public": "عمومی", + "local": "محلی", + "remote": "راه دور", + "System requirements": "نیازمندی‌های سیستم", + "Feature not enabled": "قابلیت فعال نشده است", + "Website": "وب‌سایت", + "Versions": "نسخه‌ها", + "Security": "امنیت", + "Recovery code": "کد بازیابی", + "Use a recovery code": "استفاده از کد بازیابی", + "Authentication code": "کد احراز هویت", + "Secret copied": "کلید محرمانه کپی شد", + "Recovery codes copied": "کدهای بازیابی کپی شدند", + "Invalid code": "کد نامعتبر", + "Incorrect code or password": "کد یا رمز عبور نادرست", + "Application Passwords": "رمزهای عبور برنامه", + "Generate a new app password": "تولید رمز عبور جدید برای برنامه", + "Generated password": "رمز عبور تولید شده", + "Password generated": "رمز عبور تولید شد", + "Existing passwords": "رمزهای عبور موجود", + "This password will only be shown once after it is generated": "این رمز عبور فقط یک بار پس از تولید نمایش داده می‌شود", + "Password name": "نام رمز عبور", + "Manage": "مدیریت", + "Manage app passwords": "مدیریت رمزهای عبور برنامه", + "Password Authentication": "احراز هویت با رمز عبور", + "Two-Factor Authentication": "احراز هویت دو مرحله‌ای", + "Two-Factor Authentication is enabled": "احراز هویت دو مرحله‌ای فعال است", + "Two-Factor Authentication is disabled": "احراز هویت دو مرحله‌ای غیرفعال است", + "Scan this QR code using your authenticator app.": "این کد QR را با برنامه احراز هویت خود اسکن کنید.", + "(Such as FreeOTP, Proton Authenticator etc.)": "(مانند FreeOTP، Proton Authenticator و غیره)", + "Or enter this secret manually:": "یا این کلید محرمانه را به صورت دستی وارد کنید:", + "Valid with your TOTP code": "تأیید با کد TOTP شما", + "The secret has expired": "کلید محرمانه منقضی شده است", + "Keep these codes in a safe place. They will allow you to access your account if you lose access to your two-factor authentication.": "این کدها را در جای امنی نگه دارید. در صورت از دست دادن دسترسی به احراز هویت دو مرحله‌ای، به شما امکان دسترسی به حساب کاربری را می‌دهند.", + "These recovery codes are for one-time use only and will only be displayed here once.": "این کدهای بازیابی فقط یک بار قابل استفاده هستند و فقط یک بار در اینجا نمایش داده می‌شوند.", + "Overwrite Existing File(s)": "بازنویسی فایل(های) موجود", + "Replace Existing File": "جایگزینی فایل موجود", + "Do you want to replace the existing file(s)?": "آیا می‌خواهید فایل(های) موجود را جایگزین کنید؟", + "RenameFileToExisting": "آیا می‌خواهید {{ old }} را به {{ new }} تغییر نام دهید؟", + "Save": "ذخیره", + "Line Wrap": "شکستن خط", + "Read-only": "فقط خواندنی", + "Read-write": "خواندن/نوشتن", + "Save And Exit": "ذخیره و خروج", + "Close Without Saving": "بستن بدون ذخیره", + "Undo": "برگرداندن", + "Redo": "بازگردانی", + "Visual Mode": "حالت بصری", + "Code Mode": "حالت کد", + "Heading": "عنوان", + "Bold": "پررنگ", + "Italic": "کج", + "Underline": "زیرخط", + "Strikethrough": "خط‌خورده", + "Bullet List": "لیست گلوله‌ای", + "Ordered List": "لیست شماره‌دار", + "Task List": "لیست وظایف", + "Code": "کد", + "Inline Code": "کد درون خطی", + "Code Block": "بلوک کد", + "Blockquote": "نقل قول", + "Table": "جدول", + "Create a table": "ایجاد جدول", + "Delete table": "حذف جدول", + "Add row above": "افزودن سطر در بالا", + "Add row below": "افزودن سطر در پایین", + "Delete row": "حذف سطر", + "Add column left": "افزودن ستون در چپ", + "Add column right": "افزودن ستون در راست", + "Delete column": "حذف ستون", + "Horizontal Rule": "خط افقی", + "Image": "تصویر", + "Image from file": "تصویر از فایل", + "Image from URL": "تصویر از نشانی اینترنتی", + "me": "من", + "The file is locked by": "فایل قفل شده است توسط", + "Unlock": "باز کردن قفل", + "Lock": "قفل", + "Exclusive": "انحصاری", + "Send an unlock request": "ارسال درخواست باز کردن قفل", + "sends you a request to unlock the file": "برای شما درخواست باز کردن قفل فایل را ارسال می‌کند", + "As the file owner, you can unlock the file or request the current lock owner to release it.": "به عنوان مالک فایل، می‌توانید قفل فایل را باز کنید یا از مالک فعلی قفل بخواهید آن را آزاد کند.", + "The file is edited by": "فایل در حال ویرایش است توسط", + "A new update is available": "یک به‌روزرسانی جدید در دسترس است", + "Fullscreen": "تمام صفحه", + "Dimensions": "ابعاد", + "Start Slideshow": "شروع نمایش اسلاید", + "Unable to load OnlyOffice editor": "امکان بارگذاری ویرایشگر OnlyOffice وجود ندارد", + "The document server may be unreachable or the configuration is invalid": "سرور سند ممکن است در دسترس نباشد یا پیکربندی نامعتبر است", + "OnlyOffice editor failed to initialize": "راه‌اندازی ویرایشگر OnlyOffice ناموفق بود", + "DocsAPI not available": "DocsAPI در دسترس نیست", + "Unknown OnlyOffice error": "خطای ناشناخته OnlyOffice", + "No editor found": "ویرایشگری یافت نشد", + "Choose which editor you want to use to open this document.": "ویرایشگری را که می‌خواهید برای باز کردن این سند استفاده کنید انتخاب نمایید.", + "Open-source office suite built on LibreOffice.": "مجموعه اداری متن‌باز مبتنی بر LibreOffice.", + "Open-source office suite for Microsoft Office.": "مجموعه اداری متن‌باز برای Microsoft Office.", + "Remember my choice for next time": "انتخاب من را برای دفعات بعد به خاطر بسپار", + "Document editor": "ویرایشگر سند", + "Ask Me": "از من بپرس", + "Used when both editors support the file type.": "زمانی استفاده می‌شود که هر دو ویرایشگر از نوع فایل پشتیبانی کنند.", + "Enable content indexation for personal files.": "فعال‌سازی نمایه‌سازی محتوا برای فایل‌های شخصی.", + "Actions": "عملیات‌ها", + "Delete all indexes.": "حذف همه نمایه‌ها.", + "Full-text indexing": "نمایه‌سازی تمام متن", + "Last full run": "آخرین اجرای کامل", + "Last partial run": "آخرین اجرای جزئی", + "Never": "هرگز", + "Start or stop indexing.": "شروع یا توقف نمایه‌سازی.", + "Start": "شروع", + "State": "وضعیت", + "Stop": "توقف", + "Indexes": "نمایه‌ها", + "pending": "در انتظار", + "running": "در حال اجرا", + "stopping": "در حال توقف", + "idle": "آماده به کار", + "Preferences": "ترجیحات", + "of": "از", + "Sidebar auto-hide enabled": "پنهان‌سازی خودکار نوار کناری فعال است", + "Sidebar pinned": "نوار کناری سنجاق شده", + "Continue with OpenID Connect": "ادامه با OpenID Connect", + "Link identity": "پیوند هویت", + "Share details": "جزئیات اشتراک‌گذاری", + "Access rules": "قوانین دسترسی", + "Guest profile": "نمایه مهمان", + "Version": "نسخه", + "Filter type": "نوع فیلتر", + "Filter value": "مقدار فیلتر", + "No filter configured": "هیچ فیلتری پیکربندی نشده است", + "checksums are not identical": "جمع‌های کنترلی یکسان نیستند", + "size has changed since parsing": "اندازه از زمان تجزیه تغییر کرده است", + "No synchronized folder": "پوشه همگام‌سازی شده‌ای وجود ندارد", + "Edit in OnlyOffice": "ویرایش در OnlyOffice", + "View in PDF.js": "مشاهده در PDF.js" +} diff --git a/frontend/src/i18n/l10n.ts b/frontend/src/i18n/l10n.ts index 9d4df0a7..94cea45a 100755 --- a/frontend/src/i18n/l10n.ts +++ b/frontend/src/i18n/l10n.ts @@ -26,6 +26,7 @@ export const i18nLanguageText: Record language !== USER_LANGUAGE_AUTO) - .map((language) => ({ locale: { language }, dir: 'ltr' as const })) + .map((language) => ({ locale: { language }, dir: language === 'fa' ? ('rtl' as const) : ('ltr' as const) })) export const l10nConfig: L10nConfig & { schema: L10nSchema diff --git a/frontend/src/i18n/lib/bs.i18n.ts b/frontend/src/i18n/lib/bs.i18n.ts index a2082001..f25d34db 100644 --- a/frontend/src/i18n/lib/bs.i18n.ts +++ b/frontend/src/i18n/lib/bs.i18n.ts @@ -3,6 +3,7 @@ import { defineLocale, deLocale, esLocale, + faLocale, frLocale, hiLocale, itLocale, @@ -21,6 +22,7 @@ import { const BOOTSTRAP_LOCALES: Record, LocaleData> = { de: deLocale, es: esLocale, + fa: faLocale, fr: frLocale, hi: hiLocale, it: itLocale, diff --git a/frontend/src/i18n/lib/dayjs.i18n.ts b/frontend/src/i18n/lib/dayjs.i18n.ts index 0402eec1..5712e016 100644 --- a/frontend/src/i18n/lib/dayjs.i18n.ts +++ b/frontend/src/i18n/lib/dayjs.i18n.ts @@ -5,6 +5,7 @@ const DAYJS_LOADER: Record Promise> = { de: () => import('dayjs/esm/locale/de'), en: () => import('dayjs/esm/locale/en'), es: () => import('dayjs/esm/locale/es'), + fa: () => import('dayjs/esm/locale/fa'), fr: () => import('dayjs/esm/locale/fr'), hi: () => import('dayjs/esm/locale/hi'), it: () => import('dayjs/esm/locale/it'), From def8495a564e3393169329b3677036d1ebaa1e35 Mon Sep 17 00:00:00 2001 From: AmirHosseinZg Date: Fri, 5 Jun 2026 02:48:08 +0330 Subject: [PATCH 3/9] feat(rtl): add RTL direction support for Persian locale --- frontend/src/app/layout/layout.service.ts | 13 ++++++ frontend/src/index.html | 2 +- frontend/src/styles/components/_app.scss | 41 ++++++++++--------- frontend/src/styles/components/_boxes.scss | 12 +++--- frontend/src/styles/components/_buttons.scss | 4 +- frontend/src/styles/components/_chats.scss | 2 +- .../src/styles/components/_contextmenu.scss | 2 +- frontend/src/styles/components/_core.scss | 14 +++---- .../src/styles/components/_dropdowns.scss | 4 +- frontend/src/styles/components/_forms.scss | 8 ++-- frontend/src/styles/components/_header.scss | 18 ++++---- .../src/styles/components/_sidebar_left.scss | 20 ++++----- .../components/_sidebar_left_collapse.scss | 8 ++-- .../src/styles/components/_sidebar_right.scss | 18 ++++---- 14 files changed, 90 insertions(+), 76 deletions(-) diff --git a/frontend/src/app/layout/layout.service.ts b/frontend/src/app/layout/layout.service.ts index 78f2fe23..8ca72ad8 100644 --- a/frontend/src/app/layout/layout.service.ts +++ b/frontend/src/app/layout/layout.service.ts @@ -1,5 +1,6 @@ import { HttpErrorResponse } from '@angular/common/http' import { inject, Injectable, NgZone, signal, WritableSignal } from '@angular/core' +import { DOCUMENT } from '@angular/common' import { Title } from '@angular/platform-browser' import { FaConfig } from '@fortawesome/angular-fontawesome' import { IconDefinition } from '@fortawesome/fontawesome-svg-core' @@ -55,6 +56,7 @@ export class LayoutService { public modalRefs = new Map() public collapseRSideBarPreference: WritableSignal = signal(this.getAutoCollapseRSideBarPreference()) // Services + private readonly document = inject(DOCUMENT) private readonly title = inject(Title) private readonly ngZone = inject(NgZone) private readonly translation = inject(L10nTranslationService) @@ -89,6 +91,15 @@ export class LayoutService { this.faConfig.fixedWidth = true this.title.setTitle(APP_NAME) this.preferTheme.subscribe((theme) => this.setTheme(theme)) + this.initDir() + } + + private initDir(): void { + const locale = this.translation.getLocale() + if (locale?.language) { + this.document.documentElement.dir = locale.language === 'fa' ? 'rtl' : 'ltr' + this.document.documentElement.lang = locale.language + } } showRSideBarTab(tabName: TAB_MENU = null, tabVisible = false, delay: number = 0) { @@ -258,6 +269,8 @@ export class LayoutService { language = getBrowserL10nLocale().language } if (language && language !== this.getCurrentLanguage()) { + this.document.documentElement.dir = language === 'fa' ? 'rtl' : 'ltr' + this.document.documentElement.lang = language return this.translation.setLocale({ language }) } return Promise.resolve() diff --git a/frontend/src/index.html b/frontend/src/index.html index f65c0633..50111719 100644 --- a/frontend/src/index.html +++ b/frontend/src/index.html @@ -5,7 +5,7 @@ --> - + Sync-in diff --git a/frontend/src/styles/components/_app.scss b/frontend/src/styles/components/_app.scss index f49c642d..70502434 100755 --- a/frontend/src/styles/components/_app.scss +++ b/frontend/src/styles/components/_app.scss @@ -26,7 +26,7 @@ .header { margin-bottom: .6rem; - text-align: left; + text-align: start; .auth-logo { display: block; @@ -139,16 +139,16 @@ @extend .container-fluid; padding-top: .4rem; margin-top: 0; - padding-left: 0; - padding-right: 0; + padding-inline-start: 0; + padding-inline-end: 0; .row { - margin-right: 0.4rem; - margin-left: 0.4rem; + margin-inline-end: 0.4rem; + margin-inline-start: 0.4rem; font-size: $font-size-xs; .card { - margin-right: 0.2rem; + margin-inline-end: 0.2rem; margin-bottom: 0.2rem; border-width: .5px; border-color: transparent; @@ -184,7 +184,7 @@ font-weight: 500; position: absolute; bottom: 28px; - right: .2rem; + inset-inline-end: .2rem; > span { margin-top: 0.15rem; @@ -196,7 +196,7 @@ font-weight: 500; position: absolute; bottom: 28px; - left: .2rem; + inset-inline-start: .2rem; } .label-top-right { @@ -204,7 +204,7 @@ font-weight: 500; position: absolute; top: .2rem; - right: .2rem; + inset-inline-end: .2rem; } .label-top-left { @@ -212,7 +212,7 @@ font-weight: 500; position: absolute; top: .2rem; - left: .2rem; + inset-inline-start: .2rem; } } @@ -293,7 +293,7 @@ tbody td:first-child { img, fa-icon { - margin-right: 0.3rem; + margin-inline-end: 0.3rem; } } @@ -314,7 +314,7 @@ } .avatar-stack > * + * { - margin-left: calc(var(--avatar-overlap) * -1); + margin-inline-start: calc(var(--avatar-overlap) * -1); } .avatar-stack > * { @@ -334,7 +334,7 @@ .btn.me-1, .btn-group.me-1 { // handles space between button - margin-right: .4rem !important; + margin-inline-end: .4rem !important; } .btn { @@ -348,7 +348,7 @@ fa-icon { font-size: $font-size-md; - margin-right: 0.2rem; + margin-inline-end: 0.2rem; } } } @@ -366,7 +366,7 @@ align-items: center; fa-icon { - margin-right: 0.4rem; + margin-inline-end: 0.4rem; font-size: $font-size-base; } } @@ -399,6 +399,7 @@ app-input-filter { font-size: $font-size-sm; line-height: 1.5; padding: 0 31px 0 29px; + padding-inline: 29px 31px; &::placeholder { font-size: $font-size-sm; @@ -409,14 +410,14 @@ app-input-filter { .filter-icon { position: absolute; - left: 9px; + inset-inline-start: 9px; pointer-events: none; font-size: $font-size-xs; } .clear-btn { position: absolute; - right: 6px; + inset-inline-end: 6px; display: inline-flex; align-items: center; justify-content: center; @@ -448,8 +449,8 @@ app-input-filter { width: 100%; font-size: $font-size-xs; height: 24px; - padding-left: 5px; - padding-right: 5px; + padding-inline-start: 5px; + padding-inline-end: 5px; background-color: transparent; color: transparent; @@ -506,7 +507,7 @@ app-input-filter { .info-box { position: absolute; bottom: 0; - right: 0; + inset-inline-end: 0; width: $control-sidebar-width; } } diff --git a/frontend/src/styles/components/_boxes.scss b/frontend/src/styles/components/_boxes.scss index db67a613..46327112 100644 --- a/frontend/src/styles/components/_boxes.scss +++ b/frontend/src/styles/components/_boxes.scss @@ -15,7 +15,7 @@ font-size: $font-size-base; &:first-child { - margin-right: 10px; + margin-inline-end: 10px; } } @@ -81,7 +81,7 @@ > .inner { display: flex; align-items: center; - padding-left: 10px; + padding-inline-start: 10px; } h4 { @@ -111,8 +111,8 @@ .icon { transition: all $transition-speed linear; align-self: center; - margin-left: auto; - padding-right: 10px; + margin-inline-start: auto; + padding-inline-end: 10px; z-index: 0; font-size: $font-size-2xl; color: rgba(0, 0, 0, 0.15); @@ -132,8 +132,8 @@ // No need for icons on very small devices .small-box { .icon { - margin-right: auto; - padding-right: 0; + margin-inline-end: auto; + padding-inline-end: 0; } .inner { diff --git a/frontend/src/styles/components/_buttons.scss b/frontend/src/styles/components/_buttons.scss index 9bfcfe8f..37bc8b47 100644 --- a/frontend/src/styles/components/_buttons.scss +++ b/frontend/src/styles/components/_buttons.scss @@ -112,7 +112,7 @@ .btn-custom { @extend .btn-secondary; - margin-right: 0.2rem; + margin-inline-end: 0.2rem; transition: background-color .2s ease, border-color .2s ease, color .2s ease; } @@ -136,7 +136,7 @@ input { position: absolute; top: 0; - right: 0; + inset-inline-end: 0; margin: 0; padding: 0; opacity: 0; diff --git a/frontend/src/styles/components/_chats.scss b/frontend/src/styles/components/_chats.scss index 659b8a3c..ac7f1b9f 100644 --- a/frontend/src/styles/components/_chats.scss +++ b/frontend/src/styles/components/_chats.scss @@ -16,7 +16,7 @@ border: 1px solid rgba(255, 255, 255, 0.9); position: absolute; bottom: 0; - left: 0; + inset-inline-start: 0; border-radius: 50%; } diff --git a/frontend/src/styles/components/_contextmenu.scss b/frontend/src/styles/components/_contextmenu.scss index fe7eda4d..2825b169 100644 --- a/frontend/src/styles/components/_contextmenu.scss +++ b/frontend/src/styles/components/_contextmenu.scss @@ -25,7 +25,7 @@ align-items: center; > fa-icon { - margin-right: 0.4rem; + margin-inline-end: 0.4rem; font-size: $font-size-md; } } diff --git a/frontend/src/styles/components/_core.scss b/frontend/src/styles/components/_core.scss index 4b6b6286..5c97e0a2 100644 --- a/frontend/src/styles/components/_core.scss +++ b/frontend/src/styles/components/_core.scss @@ -44,19 +44,19 @@ body { .content-wrapper { display: flex; position: relative; - margin-left: $sidebar-apps-menus-width; - margin-right: $control-sidebar-menu-width; + margin-inline-start: $sidebar-apps-menus-width; + margin-inline-end: $control-sidebar-menu-width; padding-top: $navbar-height; flex: 1 1 auto; z-index: $content-z-index; - transition: margin-left $transition-speed $transition-fn, margin-right $transition-speed $transition-fn; + transition: margin-inline-start $transition-speed $transition-fn, margin-inline-end $transition-speed $transition-fn; .content-container { - padding-left: 0; - padding-right: 0; + padding-inline-start: 0; + padding-inline-end: 0; width: 100%; - margin-right: auto; - margin-left: auto; + margin-inline-end: auto; + margin-inline-start: auto; --bs-gutter-x: 1.5rem; --bs-gutter-y: 0; } diff --git a/frontend/src/styles/components/_dropdowns.scss b/frontend/src/styles/components/_dropdowns.scss index 0d5a55fc..771adb83 100644 --- a/frontend/src/styles/components/_dropdowns.scss +++ b/frontend/src/styles/components/_dropdowns.scss @@ -33,7 +33,7 @@ // Shares Dialog button { &.dropdown-item { - padding-left: 5px; - padding-right: 5px; + padding-inline-start: 5px; + padding-inline-end: 5px; } } diff --git a/frontend/src/styles/components/_forms.scss b/frontend/src/styles/components/_forms.scss index 14cd7718..4076d2d4 100644 --- a/frontend/src/styles/components/_forms.scss +++ b/frontend/src/styles/components/_forms.scss @@ -23,11 +23,11 @@ .form-validation { .ng-valid:not(.form-check-input), .ng-valid[required], .ng-valid.required { - border-left: 5px solid $primary; + border-inline-start: 5px solid $primary; } .ng-invalid:not(form) { - border-left: 5px solid $red !important; + border-inline-start: 5px solid $red !important; } } @@ -37,11 +37,11 @@ @include no-select-utility; input { - margin-right: 0.25rem; + margin-inline-end: 0.25rem; } } .form-check-input { - margin-right: 0 !important; + margin-inline-end: 0 !important; } diff --git a/frontend/src/styles/components/_header.scss b/frontend/src/styles/components/_header.scss index 7b177e56..a3858663 100644 --- a/frontend/src/styles/components/_header.scss +++ b/frontend/src/styles/components/_header.scss @@ -3,17 +3,17 @@ top: 0; right: 0; left: 0; - margin-left: $sidebar-apps-menus-width; + margin-inline-start: $sidebar-apps-menus-width; max-height: $navbar-height; z-index: $sidebar-z-index; transform: translate3d(0, 0, 0); - transition: margin-left $transition-speed $transition-fn; + transition: margin-inline-start $transition-speed $transition-fn; .navbar { display: flex; width: 100%; margin-bottom: 0; - margin-left: 0; + margin-inline-start: 0; max-height: $navbar-height; border: none; @include border-radius(0); @@ -96,16 +96,16 @@ .navbar-history-button-left { margin-left: -.5px; @include media-breakpoint-up(sm) { - border-left: .5px solid mix($sidebar-light-icons-menu-hover-bg, $primary, 50%); + border-inline-start: .5px solid mix($sidebar-light-icons-menu-hover-bg, $primary, 50%); } } .navbar-history-button-right { - border-right: none; + border-inline-end: none; } .navbar-breadcrumb { - border-left: .5px solid mix($sidebar-light-icons-menu-hover-bg, $primary, 50%); + border-inline-start: .5px solid mix($sidebar-light-icons-menu-hover-bg, $primary, 50%); flex: 1 1 auto; min-width: 0; max-width: none; @@ -117,7 +117,7 @@ .navbar-right-side { display: flex; - margin-left: auto; + margin-inline-start: auto; flex: 0 0 auto; min-width: $sidebar-apps-icons-width; @@ -235,7 +235,7 @@ > a { color: darken($gray-light, 10%); text-decoration: none; - margin-left: .3rem; + margin-inline-start: .3rem; &:hover { color: $gray-lightest; @@ -249,7 +249,7 @@ > fa-icon { color: $sidebar-light-apps-menu-title-color; - margin-left: .3rem; + margin-inline-start: .3rem; } } } diff --git a/frontend/src/styles/components/_sidebar_left.scss b/frontend/src/styles/components/_sidebar_left.scss index 82b30497..07aae03b 100755 --- a/frontend/src/styles/components/_sidebar_left.scss +++ b/frontend/src/styles/components/_sidebar_left.scss @@ -2,7 +2,7 @@ display: flex; position: fixed; top: 0; - left: 0; + inset-inline-start: 0; height: 100%; z-index: $sidebar-z-index; @@ -50,8 +50,8 @@ padding: 0; border: none; border-radius: 50%; - margin-right: .4rem; - margin-left: .4rem; + margin-inline-end: .4rem; + margin-inline-start: .4rem; background: $black; cursor: pointer; @@ -64,7 +64,7 @@ .sidebar-title { @include text-truncate-utility; text-transform: uppercase; - margin-right: auto; + margin-inline-end: auto; font-size: $font-size-xs; > span { @@ -72,7 +72,7 @@ } > fa-icon { - margin-right: .25rem; + margin-inline-end: .25rem; font-size: $font-size-lg; } } @@ -87,7 +87,7 @@ min-height: 40px; max-height: 40px; border-bottom: .5px solid transparent; - border-right: .5px solid transparent; + border-inline-end: .5px solid transparent; .app-shortcut { flex: 0 0 calc(#{$sidebar-apps-height} - 7px); @@ -155,7 +155,7 @@ cursor: pointer; &.submenu { - padding-left: 10px; + padding-inline-start: 10px; font-size: $font-size-sm; fa-icon { @@ -179,7 +179,7 @@ > fa-icon { font-size: $font-size-lg; - margin-right: 0; + margin-inline-end: 0; width: .95rem; min-width: .95rem; display: inline-flex; @@ -188,7 +188,7 @@ } .menu-badge { - margin-left: auto; + margin-inline-start: auto; font-size: $font-size-xs; } } @@ -202,7 +202,7 @@ } display: flex; padding: 4px; - margin-left: auto; + margin-inline-start: auto; margin-top: auto; span { diff --git a/frontend/src/styles/components/_sidebar_left_collapse.scss b/frontend/src/styles/components/_sidebar_left_collapse.scss index f123f9fb..60b2883a 100755 --- a/frontend/src/styles/components/_sidebar_left_collapse.scss +++ b/frontend/src/styles/components/_sidebar_left_collapse.scss @@ -1,11 +1,11 @@ .sidebar-collapse { .main-header, .content-wrapper { - margin-left: 0 !important; + margin-inline-start: 0 !important; } &.theme-dark { .content-container { - border-left: 0.5px solid $sidebar-dark-icons-menu-border-color !important; + border-inline-start: 0.5px solid $sidebar-dark-icons-menu-border-color !important; } } @@ -17,7 +17,7 @@ .sidebar-apps-menus { transform: translate3d(0, 0, 0); transition: width $transition-speed $transition-fn; - border-right: none !important; + border-inline-end: none !important; overflow: hidden; width: 0; } @@ -47,7 +47,7 @@ } @include media-breakpoint-down(md) { .main-header, .content-wrapper { - margin-left: 0; + margin-inline-start: 0; } } } diff --git a/frontend/src/styles/components/_sidebar_right.scss b/frontend/src/styles/components/_sidebar_right.scss index 0e87be49..58fca5b9 100755 --- a/frontend/src/styles/components/_sidebar_right.scss +++ b/frontend/src/styles/components/_sidebar_right.scss @@ -1,21 +1,21 @@ .control-sidebar { top: 0; - right: -$control-sidebar-width; + inset-inline-end: -$control-sidebar-width; width: $control-sidebar-width; position: fixed; height: 100%; margin-top: $navbar-height; z-index: $control-sidebar-menu-z-index; transform: translate3d(0, 0, 0); - transition: right $transition-speed $transition-fn; + transition: inset-inline-end $transition-speed $transition-fn; //Open state with slide over content effect &.control-sidebar-open { - right: $control-sidebar-menu-width; + inset-inline-end: $control-sidebar-menu-width; } @include media-breakpoint-down(sm) { - right: -$control-sidebar-small-width; + inset-inline-end: -$control-sidebar-small-width; width: $control-sidebar-small-width; } @@ -32,8 +32,8 @@ .sidebar-component-title { > span { - margin-right: auto; - margin-left: auto; + margin-inline-end: auto; + margin-inline-start: auto; } } @@ -59,7 +59,7 @@ display: flex; position: fixed; top: 0; - right: 0; + inset-inline-end: 0; padding-top: $navbar-height; width: $control-sidebar-menu-width; height: 100%; @@ -106,7 +106,7 @@ @extend .badge-small; @extend .badge-color-full; position: absolute; - right: 3px; + inset-inline-end: 3px; bottom: 1px; } } @@ -118,7 +118,7 @@ .control-sidebar-open { @include media-breakpoint-up(lg) { .content-wrapper { - margin-right: $control-sidebar-width + $control-sidebar-menu-width; + margin-inline-end: $control-sidebar-width + $control-sidebar-menu-width; } } } From 8b0b6dd94fef8f998e0acaa2977513011422768a Mon Sep 17 00:00:00 2001 From: AmirHosseinZg Date: Fri, 5 Jun 2026 02:48:15 +0330 Subject: [PATCH 4/9] feat(date): integrate Jalali calendar support via jalaliday --- frontend/package.json | 1 + frontend/src/app/common/pipes/time-ago.pipe.ts | 5 ++++- .../src/app/common/pipes/time-date-format.pipe.ts | 11 +++++++++-- frontend/src/app/common/utils/jalaliday.d.ts | 9 +++++++++ frontend/src/app/common/utils/time.ts | 2 ++ package-lock.json | 10 ++++++++++ 6 files changed, 35 insertions(+), 3 deletions(-) create mode 100644 frontend/src/app/common/utils/jalaliday.d.ts diff --git a/frontend/package.json b/frontend/package.json index b8db852f..716704b0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -51,6 +51,7 @@ "angular-l10n": "^17.0.0", "codemirror": "^6.0.2", "dayjs": "^1.11.13", + "jalaliday": "^3.1.1", "ngx-bootstrap": "^20.0.0", "ngx-clipboard": "^16.0.0", "ngx-socket-io": "4.9.3", diff --git a/frontend/src/app/common/pipes/time-ago.pipe.ts b/frontend/src/app/common/pipes/time-ago.pipe.ts index 8f962e66..0b5da5fd 100644 --- a/frontend/src/app/common/pipes/time-ago.pipe.ts +++ b/frontend/src/app/common/pipes/time-ago.pipe.ts @@ -1,10 +1,12 @@ import { ChangeDetectorRef, inject, NgZone, OnDestroy, Pipe, PipeTransform } from '@angular/core' +import { L10N_LOCALE, L10nLocale } from 'angular-l10n' import { Dayjs } from 'dayjs/esm' import { dJs } from '../utils/time' @Pipe({ name: 'amTimeAgo', pure: false }) export class TimeAgoPipe implements PipeTransform, OnDestroy { private readonly cdRef = inject(ChangeDetectorRef) + private readonly locale = inject(L10N_LOCALE) private ngZone = inject(NgZone) private currentTimer: number | null private lastTime: number @@ -15,7 +17,8 @@ export class TimeAgoPipe implements PipeTransform, OnDestroy { private formatFn: (d: Dayjs) => string format(d: Dayjs) { - return d.from(dJs(), this.lastOmitSuffix) + const instance = this.locale?.language === 'fa' ? d.calendar('jalali').locale('fa') : d + return instance.from(dJs(), this.lastOmitSuffix) } transform(value: any, omitSuffix?: boolean, formatFn?: (m: Dayjs) => string): string { diff --git a/frontend/src/app/common/pipes/time-date-format.pipe.ts b/frontend/src/app/common/pipes/time-date-format.pipe.ts index 5dc0c870..e434db66 100644 --- a/frontend/src/app/common/pipes/time-date-format.pipe.ts +++ b/frontend/src/app/common/pipes/time-date-format.pipe.ts @@ -1,12 +1,19 @@ -import { Pipe, PipeTransform } from '@angular/core' +import { inject, Pipe, PipeTransform } from '@angular/core' +import { L10N_LOCALE, L10nLocale } from 'angular-l10n' import { dJs } from '../utils/time' @Pipe({ name: 'amDateFormat' }) export class TimeDateFormatPipe implements PipeTransform { + private readonly locale = inject(L10N_LOCALE) + transform(value: any, format = 'L HH:mm:ss'): string { if (!value) { return '' } - return dJs(value).format(format) + const date = dJs(value) + if (this.locale?.language === 'fa') { + return date.calendar('jalali').locale('fa').format(format) + } + return date.format(format) } } diff --git a/frontend/src/app/common/utils/jalaliday.d.ts b/frontend/src/app/common/utils/jalaliday.d.ts new file mode 100644 index 00000000..8af6a386 --- /dev/null +++ b/frontend/src/app/common/utils/jalaliday.d.ts @@ -0,0 +1,9 @@ +import 'dayjs/esm' + +declare module 'dayjs/esm' { + interface Dayjs { + calendar: (type: 'jalali' | 'gregory') => Dayjs & { + calendar: (type: 'jalali' | 'gregory') => Dayjs + } + } +} diff --git a/frontend/src/app/common/utils/time.ts b/frontend/src/app/common/utils/time.ts index 76d4bc21..543b0d7f 100644 --- a/frontend/src/app/common/utils/time.ts +++ b/frontend/src/app/common/utils/time.ts @@ -3,10 +3,12 @@ import duration from 'dayjs/esm/plugin/duration' import localizedFormat from 'dayjs/esm/plugin/localizedFormat' import relativeTime from 'dayjs/esm/plugin/relativeTime' import utc from 'dayjs/esm/plugin/utc' +import jalaliday from 'jalaliday/dayjs' dayjs.extend(relativeTime) dayjs.extend(localizedFormat) dayjs.extend(utc) dayjs.extend(duration) +dayjs.extend(jalaliday as any) export { dayjs as dJs } diff --git a/package-lock.json b/package-lock.json index d5034ca0..bd4f3007 100644 --- a/package-lock.json +++ b/package-lock.json @@ -331,6 +331,7 @@ "angular-l10n": "^17.0.0", "codemirror": "^6.0.2", "dayjs": "^1.11.13", + "jalaliday": "^3.1.1", "ngx-bootstrap": "^20.0.0", "ngx-clipboard": "^16.0.0", "ngx-socket-io": "4.9.3", @@ -18108,6 +18109,15 @@ "@pkgjs/parseargs": "^0.11.0" } }, + "node_modules/jalaliday": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/jalaliday/-/jalaliday-3.1.1.tgz", + "integrity": "sha512-exZzDOfp/32Gj2YkEHNhBBMcltBCTrIe8RXLTfpCvdjYHNfbYSQiNeFVf4RR59FV+2fixUOCEFfzOzyXfgenlg==", + "license": "MIT", + "peerDependencies": { + "dayjs": "^1.11.13" + } + }, "node_modules/jose": { "version": "6.2.3", "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", From 39b81c196b64d1fb33d079a5b57258db36636b16 Mon Sep 17 00:00:00 2001 From: AmirHosseinZg Date: Fri, 5 Jun 2026 02:48:20 +0330 Subject: [PATCH 5/9] test(i18n): add fa locale unit tests --- backend/src/common/i18n.spec.ts | 39 +++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 backend/src/common/i18n.spec.ts diff --git a/backend/src/common/i18n.spec.ts b/backend/src/common/i18n.spec.ts new file mode 100644 index 00000000..75163286 --- /dev/null +++ b/backend/src/common/i18n.spec.ts @@ -0,0 +1,39 @@ +import { LANG_SUPPORTED, normalizeLanguage } from './i18n' + +describe('i18n', () => { + describe('LANG_SUPPORTED', () => { + it('should include fa (Persian)', () => { + expect(LANG_SUPPORTED.has('fa')).toBe(true) + }) + + it('should include previously supported languages', () => { + expect(LANG_SUPPORTED.has('en')).toBe(true) + expect(LANG_SUPPORTED.has('de')).toBe(true) + expect(LANG_SUPPORTED.has('fr')).toBe(true) + expect(LANG_SUPPORTED.has('es')).toBe(true) + }) + }) + + describe('normalizeLanguage', () => { + it('should return fa for fa language code', () => { + expect(normalizeLanguage('fa')).toBe('fa') + }) + + it('should return fa for fa-IR language code', () => { + expect(normalizeLanguage('fa-IR')).toBe('fa') + }) + + it('should return null for unsupported language', () => { + expect(normalizeLanguage('xx')).toBeNull() + }) + + it('should return null for empty or null input', () => { + expect(normalizeLanguage('')).toBeNull() + expect(normalizeLanguage(null as any)).toBeNull() + }) + + it('should return en for en-US language code', () => { + expect(normalizeLanguage('en-US')).toBe('en') + }) + }) +}) From 0524a3d2b9412e25b0680a6923559ab60543f6ce Mon Sep 17 00:00:00 2001 From: AmirHosseinZg Date: Fri, 5 Jun 2026 02:48:26 +0330 Subject: [PATCH 6/9] docs: add Persian support documentation and audit --- docs/contribution/fa-support-audit.md | 170 ++++++++++++++++++++++++++ 1 file changed, 170 insertions(+) create mode 100644 docs/contribution/fa-support-audit.md diff --git a/docs/contribution/fa-support-audit.md b/docs/contribution/fa-support-audit.md new file mode 100644 index 00000000..fb44a750 --- /dev/null +++ b/docs/contribution/fa-support-audit.md @@ -0,0 +1,170 @@ +# Persian (fa) + RTL + Jalali Support — Repository Audit + +## Repository Architecture + +### Overview + +Sync-in is a self-hosted file storage, synchronization, and collaboration platform. + +- **Monorepo**: npm workspaces with `backend` and `frontend` packages +- **Backend**: NestJS + Fastify (Node.js TypeScript) +- **Frontend**: Angular 20 (TypeScript) +- **Database**: MariaDB via Drizzle ORM +- **Testing**: Vitest (backend only, 68 spec files) +- **Styling**: Bootstrap 5 + custom SCSS + +### Key Files + +| File | Purpose | +|------|---------| +| `package.json` | Root workspace orchestrator, shared scripts | +| `backend/package.json` | NestJS backend dependencies and scripts | +| `frontend/package.json` | Angular frontend dependencies and scripts | +| `CONTRIBUTING.md` | Contribution guidelines, i18n instructions | + +--- + +## Localization Architecture + +### Backend i18n + +**Entry point**: `backend/src/common/i18n.ts` + +- `LANG_SUPPORTED`: Set of 14 supported languages (de, en, es, fr, hi, it, ja, ko, nl, pl, pt, pt-BR, ru, tr, zh) +- `normalizeLanguage()`: Validates and normalizes language codes +- Types: `i18nLocaleSupported`, `i18nLocale` + +**Notification translations**: `backend/src/applications/notifications/i18n/` + +- Per-language TypeScript files (e.g., `de.ts`, `fr.ts`, `pt_br.ts`) +- Pattern: `export const xx = { 'English Key': 'Translated Value', ... } as const` +- Central registry in `index.ts`: `Map>` +- `translateObject(language, obj)`: Mutates English values to translations in-place +- Used by 10 email notification types in `notifications/mails/models.ts` + +### Frontend i18n + +**Entry point**: `frontend/src/i18n/l10n.ts` + +- Uses `angular-l10n` library for translation management +- `i18nLanguageText`: Mapping of locale to display name +- `LANG_SCHEMA`: Array of `{ locale: { language }, dir: 'ltr' | 'rtl' }` +- `TranslationLoader`: Dynamic JSON imports per language +- `TranslationStorage`: sessionStorage-based locale persistence + +**Translation files**: `frontend/src/i18n/*.json` + +- 15 JSON files (de, en, es, fr, hi, it, ja, ko, nl, pl, pt, pt-BR, ru, tr, zh) +- Keys are English strings, values are translations +- ICU-style interpolation: `{{ param }}` +- ~712 keys per language file + +**Locale libraries**: `frontend/src/i18n/lib/` + +- `bs.i18n.ts`: ngx-bootstrap datepicker locales (14 languages) +- `dayjs.i18n.ts`: Day.js locale loaders (14 languages) + +### Language Switching Flow + +1. User selects language in `user-account.component.html` dropdown +2. `user-account.component.ts` calls `layout.setLanguage()` +3. `LayoutService.setLanguage()` calls `translation.setLocale({ language })` +4. `TranslationLoader.get()` triggers: + - `loadDayjsLocale(language)` — sets dayjs locale + - `loadBootstrapLocale(language)` — sets ngx-bootstrap datepicker locale + - `this.bsLocale.use(language)` — BsLocaleService + - Dynamic import of `./{language}.json` — UI translations + +--- + +## Date Handling Architecture + +### Core Utility + +**File**: `frontend/src/app/common/utils/time.ts` + +```typescript +import dayjs from 'dayjs/esm' +import duration from 'dayjs/esm/plugin/duration' +import localizedFormat from 'dayjs/esm/plugin/localizedFormat' +import relativeTime from 'dayjs/esm/plugin/relativeTime' +import utc from 'dayjs/esm/plugin/utc' +// Extends dayjs with 4 plugins +export { dayjs as dJs } +``` + +### Date Pipes + +| Pipe | Purpose | Location | +|------|---------|----------| +| `amDateFormat` | Format date with `dJs(value).format()` | `time-date-format.pipe.ts:12` | +| `amTimeAgo` | Relative time with `d.from(dJs())` | `time-ago.pipe.ts:48` | + +### Datepicker + +- ngx-bootstrap `BsDatepickerModule` +- Locale synced via `bs.i18n.ts` + `BsLocaleService.use()` +- Custom themed in `_datepicker.scss` + +### Current Libraries + +- dayjs ^1.11.13 +- No Jalali/calendar plugin imported + +--- + +## RTL Readiness + +### Current State + +- `L10nSchema` type accepts `'rtl'` as `dir` value +- **All 15 languages are hardcoded as `dir: 'ltr'`** in `LANG_SCHEMA` +- `index.html` has `` with **no `dir` attribute** +- No `dir="rtl"` or `direction: rtl` in any template or stylesheet +- No RTL-specific CSS rules exist + +### RTL Support Required + +1. **HTML**: Add dynamic `dir` and `lang` attributes +2. **SCSS**: Convert physical properties to logical properties in: + - `_sidebar_left.scss` (left positioning) + - `_sidebar_right.scss` (right positioning) + - `_sidebar_left_collapse.scss` (margin-left) + - `_app.scss` (layout properties) + - `_datepicker.scss` (review only, ngx-bootstrap handles its own RTL) +3. **Bootstrap 5**: Natively RTL-aware via `[dir="rtl"]` CSS + +### CSS Logical Properties Mapping + +| Physical | Logical | +|----------|---------| +| `margin-left` | `margin-inline-start` | +| `margin-right` | `margin-inline-end` | +| `padding-left` | `padding-inline-start` | +| `padding-right` | `padding-inline-end` | +| `left` | `inset-inline-start` | +| `right` | `inset-inline-end` | +| `text-align: left` | `text-align: start` | +| `text-align: right` | `text-align: end` | + +--- + +## Testing Architecture + +- **Framework**: Vitest v4 with `@nestjs/testing` +- **Location**: `backend/src/**/*.spec.ts` (68 files) +- **Config**: `vitest.config.mts` (unit), `vitest-e2e.config.mts` (e2e) +- **Frontend**: No test infrastructure (no spec files, no Karma/Jest/Vitest) +- **i18n coverage**: No dedicated i18n tests; language tested incidentally in user/notification services + +--- + +## Potential Risks + +| Risk | Severity | Mitigation | +|------|----------|------------| +| `jalaliday` patches dayjs prototype globally | Low | `.calendar('jalali')` is per-instance; only called when locale=`fa` | +| RTL CSS changes break existing LTR layout | Medium | Full logical properties approach; validated per-file | +| ngx-bootstrap lacks `fa` locale for datepicker | Low | Register custom Persian locale with Jalali month names | +| Translation quality for Persian | Medium | AI-assisted initial translation; review by native speaker | +| Day.js `fa` locale | Low | Official dayjs locale exists at `dayjs/locale/fa` | From ba0420c03d2aff5719a84f3f4ce6a64384a32266 Mon Sep 17 00:00:00 2001 From: AmirHosseinZg Date: Fri, 5 Jun 2026 02:49:39 +0330 Subject: [PATCH 7/9] docs: add localization analysis, jalali design, validation reports, and PR description --- docs/contribution/baseline-validation.md | 51 ++++++++++ docs/contribution/final-validation.md | 68 +++++++++++++ docs/contribution/jalali-design.md | 84 +++++++++++++++++ docs/contribution/localization-analysis.md | 69 ++++++++++++++ docs/contribution/pr-description.md | 105 +++++++++++++++++++++ 5 files changed, 377 insertions(+) create mode 100644 docs/contribution/baseline-validation.md create mode 100644 docs/contribution/final-validation.md create mode 100644 docs/contribution/jalali-design.md create mode 100644 docs/contribution/localization-analysis.md create mode 100644 docs/contribution/pr-description.md diff --git a/docs/contribution/baseline-validation.md b/docs/contribution/baseline-validation.md new file mode 100644 index 00000000..7f493b7e --- /dev/null +++ b/docs/contribution/baseline-validation.md @@ -0,0 +1,51 @@ +# Baseline Validation + +Pre-change validation results before implementing Persian (fa) + RTL + Jalali support. + +## Environment + +- **Node.js**: >= 22 +- **Date**: 2026-06-05 +- **Branch**: `feature/fa-rtl-jalali-support` (branched from `main`) + +## Result Summary + +| Check | Result | Details | +|-------|--------|---------| +| **Lint** | PASS | All files pass linting (backend + frontend) | +| **Test** | PASS | 69 test files, 977 tests all passing | +| **TypeScript** | PASS | No type errors | +| **Backend Build** | PASS | 414 files compiled successfully with SWC | + +## Lint Results + +``` +> lint +> eslint "{src,apps,libs,test}/**/*.ts" + +(no errors) + +> lint +> ng lint + +Linting "frontend"... +All files pass linting. +``` + +## Test Results + +``` +Test Files 69 passed (69) + Tests 977 passed (977) +Type Errors no errors +``` + +## Frontend Build + +Frontend build passes with `ng build --configuration development`. + +## Notes + +- Required creating `environment/environment.yaml` from the dist template +- Changed `dataPath` to `/tmp/sync-in-test` for test environment +- 2 test files originally failed due to EACCES on `/home/sync-in` (pre-existing issue, fixed by dataPath change) diff --git a/docs/contribution/final-validation.md b/docs/contribution/final-validation.md new file mode 100644 index 00000000..6931a4db --- /dev/null +++ b/docs/contribution/final-validation.md @@ -0,0 +1,68 @@ +# Final Validation + +Post-implementation validation results for Persian (fa) + RTL + Jalali support. + +## Quality Check Summary + +| Check | Result | Details | +|-------|--------|---------| +| **Lint (backend)** | PASS | No errors after auto-fix | +| **Lint (frontend)** | PASS | All files pass linting | +| **Test (backend)** | PASS | 70 test files, 984 tests all passing | +| **TypeScript** | PASS | No type errors in frontend or backend | +| **Backend Build** | PASS | 414 files compiled successfully | +| **Frontend Build** | PASS | Angular build successful | + +## Lint Results + +``` +npm run lint +------------------------------ +> lint (backend) +> eslint "{src,apps,libs,test}/**/*.ts" + +(no errors) + +> lint (frontend) +> ng lint + +Linting "frontend"... +All files pass linting. +``` + +## Test Results + +``` +npm -w backend test +------------------------------ +Test Files 70 passed (70) + Tests 984 passed (984) +Type Errors no errors +``` + +New test added: +- `backend/src/common/i18n.spec.ts` — 7 tests covering `LANG_SUPPORTED` and `normalizeLanguage` with `fa` + +## Regression Check + +- All 977 pre-existing tests continue to pass +- 7 new tests added for Persian locale registration +- No test regressions + +## Files Changed + +| Category | Files | Changes | +|----------|-------|---------| +| Backend i18n | 3 | `fa` locale, translations, registration | +| Frontend i18n | 4 | JSON translations, locale config, dayjs/bs locales | +| RTL CSS | 14 | Logical properties in all layout SCSS files | +| RTL Logic | 2 | HTML dir attribute, LayoutService direction management | +| Jalali | 5 | jalaliday integration, date pipe modifications, types | +| Tests | 1 | i18n unit tests | +| Docs | 4 | Audit, baseline, analysis, design docs | +| **Total** | **33** | | + +## Build Results + +- **Backend**: 414 files compiled with SWC in ~229ms +- **Frontend**: Angular build successful, outputs ~8MB of JS bundles diff --git a/docs/contribution/jalali-design.md b/docs/contribution/jalali-design.md new file mode 100644 index 00000000..26f7fe5e --- /dev/null +++ b/docs/contribution/jalali-design.md @@ -0,0 +1,84 @@ +# Jalali Calendar Design + +Design decisions for integrating Jalali (Persian) calendar support. + +## Context + +Sync-in uses **dayjs** for all date handling: +- `frontend/src/app/common/utils/time.ts` — Central dayjs instance with plugins +- `amDateFormat` pipe — Date formatting via `dJs(value).format()` +- `amTimeAgo` pipe — Relative time via `dJs(value).from()` + +## Options Considered + +### Option A: `jalaliday` npm package (SELECTED) + +- **Package**: `jalaliday/dayjs` — Official dayjs extension for Jalali calendar +- **API**: `dayjs().calendar('jalali').locale('fa').format(...)` +- **Scope**: Per-instance calendar selection (`.calendar('jalali')` returns a new Dayjs instance) +- **Maintenance**: Actively maintained, 6.5K+ weekly downloads +- **Risk**: Patches dayjs prototype globally, but calendar selection is per-instance + +### Option B: Custom Jalali Date Pipe + +- **Approach**: Implement Jalali ↔ Gregorian conversion from scratch +- **Pros**: Zero external dependencies +- **Cons**: More code, more maintenance, reinventing the wheel + +## Decision: Option A — `jalaliday` + +### Rationale + +1. **Idiomatic**: Extends dayjs the same way other plugins do +2. **Minimal changes**: Only 3 files modified +3. **Per-instance safety**: `.calendar('jalali')` affects only that Dayjs chain +4. **Proven**: Widely used in the Persian developer community +5. **All dayjs features preserved**: `format()`, `from()`, `diff()`, `add()`, `subtract()` all work with Jalali calendar + +## Implementation + +### File: `frontend/src/app/common/utils/time.ts` + +```typescript +import jalaliday from 'jalaliday/dayjs' +dayjs.extend(jalaliday) +``` + +### File: `frontend/src/app/common/utils/jalaliday.d.ts` + +Type augmentation to add `calendar` method to dayjs/esm Dayjs type. + +### File: `frontend/src/app/common/pipes/time-date-format.pipe.ts` + +```typescript +if (this.locale?.language === 'fa') { + return date.calendar('jalali').locale('fa').format(format) +} +return date.format(format) +``` + +### File: `frontend/src/app/common/pipes/time-ago.pipe.ts` + +Same pattern — check locale, chain `.calendar('jalali')` when Persian. + +## Calendar Behavior + +| Language | Calendar | Example Output | +|----------|----------|---------------| +| en, de, fr, ... | Gregorian | `06/05/2026 12:00:00` | +| fa | Jalali | `1405/03/15 12:00:00` | + +## Risk Mitigation + +1. **Regression**: Non-Persian users are unaffected — `.calendar('jalali')` is only called when `locale === 'fa'` +2. **Type safety**: Type augmentation file `jalaliday.d.ts` ensures TypeScript recognizes `calendar` method +3. **Dual-package hazard**: `dayjs/esm` import used for type compatibility; `jalaliday.extend` cast as `any` to resolve type mismatch between `dayjs` and `dayjs/esm` + +## Dependencies Added + +- `jalaliday` — Day.js Jalali calendar plugin + +## Testing + +- Unit test in `backend/src/common/i18n.spec.ts` validates `fa` locale registration +- Existing 977 tests continue to pass with no regressions diff --git a/docs/contribution/localization-analysis.md b/docs/contribution/localization-analysis.md new file mode 100644 index 00000000..2d5eb4c3 --- /dev/null +++ b/docs/contribution/localization-analysis.md @@ -0,0 +1,69 @@ +# Localization Analysis + +Analysis of the Sync-in localization architecture for adding Persian (fa) support. + +## Translation Format + +### Backend + +- **Format**: TypeScript constant exports +- **Pattern**: `export const xx = { 'English Key': 'Translated Value', ... } as const` +- **Keys**: English strings serve as lookup keys +- **Registration**: Map in `index.ts` via `translations.set(locale, translationObj)` +- **Usage**: `translateObject(language, obj)` mutates English values to translations + +### Frontend + +- **Format**: JSON files (`{ "English Key": "Translated Value", ... }`) +- **Keys**: English strings with ICU-style interpolation (`{{ param }}`) +- **Registration**: Dynamic import via `TranslationLoader.get()` in `l10n.ts` +- **Framework**: `angular-l10n` library + +## Naming Conventions + +- **Backend**: `xx.ts` or `xx_yy.ts` (lowercase, underscore for region) + - Examples: `de.ts`, `pt_br.ts` +- **Frontend**: `xx.json` or `xx-YY.json` (lowercase, hyphen for region) + - Examples: `de.json`, `pt-BR.json` + +## Locale Registration Pattern + +### Backend (`backend/src/common/i18n.ts`) + +```typescript +export const LANG_SUPPORTED = new Set(['de', 'en', ...] as const) +``` + +### Frontend (`frontend/src/i18n/l10n.ts`) + +```typescript +export const i18nLanguageText: Record<...> = { de: 'Deutsch', en: 'English', ... } +const LANG_SCHEMA = [...] // language + direction mapping +``` + +## Language Selector + +- Embedded `