Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions backend/src/applications/notifications/i18n/fa.ts
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions backend/src/applications/notifications/i18n/index.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -17,6 +18,7 @@ import { zh } from './zh'
export const translations = new Map<i18nLocale, Record<string, string>>([
['de', de],
['es', es],
['fa', fa],
['fr', fr],
['hi', hi],
['it', it],
Expand Down
39 changes: 39 additions & 0 deletions backend/src/common/i18n.spec.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
})
2 changes: 1 addition & 1 deletion backend/src/common/i18n.ts
Original file line number Diff line number Diff line change
@@ -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<infer T> ? T : never
export type i18nLocale = Exclude<i18nLocaleSupported, typeof LANG_DEFAULT>

Expand Down
51 changes: 51 additions & 0 deletions docs/contribution/baseline-validation.md
Original file line number Diff line number Diff line change
@@ -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)
170 changes: 170 additions & 0 deletions docs/contribution/fa-support-audit.md
Original file line number Diff line number Diff line change
@@ -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<i18nLocale, Record<string, string>>`
- `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 `<html lang="en">` 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` |
Loading