diff --git a/docs/JALALI_CALENDAR.md b/docs/JALALI_CALENDAR.md new file mode 100644 index 000000000..d628bb7bb --- /dev/null +++ b/docs/JALALI_CALENDAR.md @@ -0,0 +1,216 @@ +# Jalali Calendar Support for Deck + +This document describes the Jalali calendar (Persian/Shamsi calendar) support that has been added to the Deck application. + +## Overview + +The Deck application now supports the Jalali calendar system, which is the official calendar of Iran and Afghanistan. This feature provides: + +- Automatic detection of Persian/Farsi locale +- Conversion between Gregorian and Jalali dates +- Persian date formatting and display +- Localized day and month names +- Persian calendar-aware due date calculations + +## Features + +### 1. Automatic Locale Detection + +The system automatically detects when the user's locale is set to Persian/Farsi (`fa`, `fa-ir`, or `persian`) and switches to Jalali calendar mode. + +### 2. Date Conversion + +- **Gregorian to Jalali**: Automatically converts standard dates to Jalali format +- **Jalali to Gregorian**: Converts Jalali dates back to Gregorian for backend storage + +### 3. Localized Display + +- Persian day names (یکشنبه, دوشنبه, etc.) +- Persian month names (فروردین, اردیبهشت, etc.) +- Persian relative time expressions (امروز, فردا, etc.) + +### 4. Enhanced Components + +- **EnhancedDueDateSelector**: Due date picker with Jalali calendar support +- **EnhancedDueDate**: Due date badge with Persian calendar formatting +- **Enhanced Readable Date Mixin**: Provides calendar-aware date formatting + +## Installation + +The Jalali calendar support is automatically included when you install the Deck application. The required dependencies are: + +```json +{ + "moment-jalaali": "^0.10.0" +} +``` + +## Usage + +### For Users + +1. **Set Persian Locale**: Change your Nextcloud locale to Persian/Farsi in your user settings +2. **Automatic Switch**: The calendar will automatically switch to Jalali mode +3. **Date Display**: All dates will be shown in Persian calendar format +4. **Due Dates**: Set and view due dates using the Persian calendar + +### For Developers + +#### Using the Jalali Calendar Helper + +```javascript +import { + isPersianLocale, + toJalali, + formatJalaliDate, + getPersianMonthNames +} from './helpers/jalaliCalendar.js' + +// Check if current locale uses Persian calendar +if (isPersianLocale()) { + // Convert Gregorian date to Jalali + const jalaliDate = toJalali(new Date()) + + // Format Jalali date + const formatted = formatJalaliDate(new Date(), 'jYYYY jMMMM jD') + + // Get Persian month names + const months = getPersianMonthNames() +} +``` + +#### Using the Enhanced Date Mixin + +```javascript +import enhancedReadableDate from './mixins/enhancedReadableDate.js' + +export default { + mixins: [enhancedReadableDate], + computed: { + formattedDate() { + // Automatically uses Jalali calendar for Persian locale + return this.formatEnhancedDate(this.timestamp) + } + } +} +``` + +#### Calendar-Aware Components + +```vue + + + +``` + +## Calendar System Details + +### Jalali Calendar Structure + +- **Year**: Starts from 1 AH (622 CE) +- **Months**: 12 months, first 6 months have 31 days, next 5 have 30 days, last month has 29/30 days +- **Week**: Starts on Saturday (شنبه) +- **Leap Years**: Calculated using a 33-year cycle + +### Date Format Examples + +- **Short**: `1402/12/25` (Year/Month/Day) +- **Medium**: `25 اسفند 1402` +- **Long**: `شنبه 25 اسفند 1402` +- **Full**: `شنبه 25 اسفند 1402 ساعت 14:30` + +### Relative Time Expressions + +- **Today**: امروز +- **Tomorrow**: فردا +- **Yesterday**: دیروز +- **Next Week**: هفته آینده +- **Last Week**: هفته گذشته + +## Configuration + +### Locale Settings + +The system automatically detects the following locale codes: +- `fa` - Persian +- `fa-ir` - Persian (Iran) +- `persian` - Persian (alternative) + +### Moment.js Integration + +The system extends Moment.js with Jalali calendar support using the `moment-jalaali` plugin, providing: +- `jMoment()` method for Jalali dates +- Jalali date formatting tokens (`jYYYY`, `jM`, `jD`) +- Persian locale configuration + +## Backend Compatibility + +- **Storage**: All dates are stored in standard ISO format in the database +- **API**: The backend continues to work with standard Gregorian dates +- **Conversion**: Frontend automatically converts between calendar systems + +## Testing + +### Manual Testing + +1. Change your Nextcloud locale to Persian +2. Create a card with a due date +3. Verify the date is displayed in Jalali format +4. Check that relative time expressions use Persian text + +### Automated Testing + +The enhanced components include proper test coverage for both calendar systems. + +## Troubleshooting + +### Common Issues + +1. **Dates not converting**: Ensure the locale is properly set to Persian +2. **Formatting errors**: Check that moment-jalaali is properly imported +3. **Locale detection issues**: Verify the locale code matches supported values + +### Debug Information + +Enable console logging to see calendar initialization: +```javascript +// Check current calendar type +console.log('Current calendar:', getCurrentCalendarType()) + +// Check if Persian locale is active +console.log('Is Persian locale:', isPersianLocale()) +``` + +## Future Enhancements + +Potential improvements for future versions: +- Customizable date formats +- Additional Persian calendar features (holidays, etc.) +- Support for other non-Gregorian calendars +- Enhanced date picker with visual calendar grid + +## Contributing + +When contributing to Jalali calendar support: +1. Maintain backward compatibility with existing date functionality +2. Follow the existing code style and patterns +3. Include proper tests for both calendar systems +4. Update documentation for any new features + +## License + +This feature is part of the Deck application and follows the same licensing terms (AGPL-3.0-or-later). diff --git a/package-lock.json b/package-lock.json index 6588537db..3c61fa2d5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,6 +31,7 @@ "markdown-it-link-attributes": "^4.0.1", "markdown-it-task-checkbox": "^1.0.6", "moment": "^2.30.1", + "moment-jalaali": "^0.10.4", "p-queue": "^8.0.1", "url-search-params-polyfill": "^8.2.5", "vue": "^2.7.15", @@ -4296,258 +4297,6 @@ "@parcel/watcher-win32-x64": "2.4.1" } }, - "node_modules/@parcel/watcher-android-arm64": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.4.1.tgz", - "integrity": "sha512-LOi/WTbbh3aTn2RYddrO8pnapixAziFl6SMxHM69r3tvdSm94JtCenaKgk1GRg5FJ5wpMCpHeW+7yqPlvZv7kg==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "peer": true, - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-darwin-arm64": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.4.1.tgz", - "integrity": "sha512-ln41eihm5YXIY043vBrrHfn94SIBlqOWmoROhsMVTSXGh0QahKGy77tfEywQ7v3NywyxBBkGIfrWRHm0hsKtzA==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-darwin-x64": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.4.1.tgz", - "integrity": "sha512-yrw81BRLjjtHyDu7J61oPuSoeYWR3lDElcPGJyOvIXmor6DEo7/G2u1o7I38cwlcoBHQFULqF6nesIX3tsEXMg==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-freebsd-x64": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.4.1.tgz", - "integrity": "sha512-TJa3Pex/gX3CWIx/Co8k+ykNdDCLx+TuZj3f3h7eOjgpdKM+Mnix37RYsYU4LHhiYJz3DK5nFCCra81p6g050w==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ], - "peer": true, - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-arm-glibc": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.4.1.tgz", - "integrity": "sha512-4rVYDlsMEYfa537BRXxJ5UF4ddNwnr2/1O4MHM5PjI9cvV2qymvhwZSFgXqbS8YoTk5i/JR0L0JDs69BUn45YA==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-arm64-glibc": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.4.1.tgz", - "integrity": "sha512-BJ7mH985OADVLpbrzCLgrJ3TOpiZggE9FMblfO65PlOCdG++xJpKUJ0Aol74ZUIYfb8WsRlUdgrZxKkz3zXWYA==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-arm64-musl": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.4.1.tgz", - "integrity": "sha512-p4Xb7JGq3MLgAfYhslU2SjoV9G0kI0Xry0kuxeG/41UfpjHGOhv7UoUDAz/jb1u2elbhazy4rRBL8PegPJFBhA==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-x64-glibc": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.4.1.tgz", - "integrity": "sha512-s9O3fByZ/2pyYDPoLM6zt92yu6P4E39a03zvO0qCHOTjxmt3GHRMLuRZEWhWLASTMSrrnVNWdVI/+pUElJBBBg==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-x64-musl": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.4.1.tgz", - "integrity": "sha512-L2nZTYR1myLNST0O632g0Dx9LyMNHrn6TOt76sYxWLdff3cB22/GZX2UPtJnaqQPdCRoszoY5rcOj4oMTtp5fQ==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-win32-arm64": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.4.1.tgz", - "integrity": "sha512-Uq2BPp5GWhrq/lcuItCHoqxjULU1QYEcyjSO5jqqOK8RNFDBQnenMMx4gAl3v8GiWa59E9+uDM7yZ6LxwUIfRg==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-win32-ia32": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.4.1.tgz", - "integrity": "sha512-maNRit5QQV2kgHFSYwftmPBxiuK5u4DXjbXx7q6eKjq5dsLXZ4FJiVvlcw35QXzk0KrUecJmuVFbj4uV9oYrcw==", - "cpu": [ - "ia32" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-win32-x64": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.4.1.tgz", - "integrity": "sha512-+DvS92F9ezicfswqrvIRM2njcYJbd5mb9CUgtrHCHmvn7pPPa+nMDRu1o1bYYz/l5IB2NVGNJWiH7h1E58IF2A==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -12720,6 +12469,12 @@ "@pkgjs/parseargs": "^0.11.0" } }, + "node_modules/jalaali-js": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/jalaali-js/-/jalaali-js-1.2.8.tgz", + "integrity": "sha512-Jl/EwY84JwjW2wsWqeU4pNd22VNQ7EkjI36bDuLw31wH98WQW4fPjD0+mG7cdCK+Y8D6s9R3zLiQ3LaKu6bD8A==", + "license": "MIT" + }, "node_modules/java-properties": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/java-properties/-/java-properties-1.0.2.tgz", @@ -15979,6 +15734,29 @@ "node": "*" } }, + "node_modules/moment-jalaali": { + "version": "0.10.4", + "resolved": "https://registry.npmjs.org/moment-jalaali/-/moment-jalaali-0.10.4.tgz", + "integrity": "sha512-/eD0HeyvATznb5iE0G1BHjKRZAFEpJ9ZNUkcHwXhNgt1WJJVVzHD7+uDmqzZWVFLdbGme2gvIXKb3ezDYOXcZA==", + "license": "MIT", + "dependencies": { + "jalaali-js": "^1.2.7", + "moment": "^2.29.4", + "moment-timezone": "^0.5.46" + } + }, + "node_modules/moment-timezone": { + "version": "0.5.48", + "resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.48.tgz", + "integrity": "sha512-f22b8LV1gbTO2ms2j2z13MuPogNoh5UzxL3nzNAYKGraILnbGc9NEE6dyiiiLv46DGRb8A4kg8UKWLjPthxBHw==", + "license": "MIT", + "dependencies": { + "moment": "^2.29.4" + }, + "engines": { + "node": "*" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", diff --git a/package.json b/package.json index 41925b7f7..260d7e537 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "markdown-it-link-attributes": "^4.0.1", "markdown-it-task-checkbox": "^1.0.6", "moment": "^2.30.1", + "moment-jalaali": "^0.10.4", "p-queue": "^8.0.1", "url-search-params-polyfill": "^8.2.5", "vue": "^2.7.15", diff --git a/src/components/card/EnhancedDueDateSelector.vue b/src/components/card/EnhancedDueDateSelector.vue new file mode 100644 index 000000000..166c3e7a4 --- /dev/null +++ b/src/components/card/EnhancedDueDateSelector.vue @@ -0,0 +1,367 @@ + + + + + + diff --git a/src/components/cards/badges/EnhancedDueDate.vue b/src/components/cards/badges/EnhancedDueDate.vue new file mode 100644 index 000000000..f18f30d1c --- /dev/null +++ b/src/components/cards/badges/EnhancedDueDate.vue @@ -0,0 +1,201 @@ + + + + + + + diff --git a/src/components/demo/JalaliCalendarDemo.vue b/src/components/demo/JalaliCalendarDemo.vue new file mode 100644 index 000000000..86e60893f --- /dev/null +++ b/src/components/demo/JalaliCalendarDemo.vue @@ -0,0 +1,248 @@ + + + + + + diff --git a/src/helpers/__tests__/jalaliCalendar.test.js b/src/helpers/__tests__/jalaliCalendar.test.js new file mode 100644 index 000000000..661accc86 --- /dev/null +++ b/src/helpers/__tests__/jalaliCalendar.test.js @@ -0,0 +1,231 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import moment from '@nextcloud/moment' +import 'moment-jalaali' +import { + isPersianLocale, + getCurrentCalendarType, + toJalali, + toGregorian, + formatDate, + getRelativeTime, + getPersianDayNames, + getPersianMonthNames, + getPersianShortDayNames, + getPersianShortMonthNames, + getPersianFirstDayOfWeek, + isJalaliToday, + isJalaliTomorrow, + getJalaliComponents, + createJalaliDate, + getCurrentJalaliDate, + formatJalaliDate, + getReadableJalaliDate +} from '../jalaliCalendar.js' + +// Mock moment locale for testing +const originalLocale = moment.locale +const mockLocale = 'en' + +beforeEach(() => { + // Reset to default locale before each test + moment.locale('en') +}) + +afterEach(() => { + // Restore original locale after each test + moment.locale(originalLocale) +}) + +describe('Jalali Calendar Helper Functions', () => { + describe('isPersianLocale', () => { + it('should return false for non-Persian locales', () => { + moment.locale('en') + expect(isPersianLocale()).toBe(false) + }) + + it('should return true for Persian locales', () => { + moment.locale('fa') + expect(isPersianLocale()).toBe(true) + }) + }) + + describe('getCurrentCalendarType', () => { + it('should return gregorian for non-Persian locales', () => { + moment.locale('en') + expect(getCurrentCalendarType()).toBe('gregorian') + }) + + it('should return jalali for Persian locales', () => { + moment.locale('fa') + expect(getCurrentCalendarType()).toBe('jalali') + }) + }) + + describe('toJalali', () => { + it('should convert Gregorian date to Jalali', () => { + const gregorianDate = new Date('2024-01-15') + const jalaliDate = toJalali(gregorianDate) + + expect(jalaliDate).toBeDefined() + expect(jalaliDate.jYear()).toBe(1402) + expect(jalaliDate.jMonth()).toBe(10) // 0-based month + expect(jalaliDate.jDate()).toBe(25) + }) + + it('should handle null input', () => { + expect(toJalali(null)).toBeNull() + }) + }) + + describe('toGregorian', () => { + it('should convert Jalali date to Gregorian', () => { + const jalaliDate = moment.jMoment('1402/10/25', 'jYYYY/jM/jD') + const gregorianDate = toGregorian(jalaliDate) + + expect(gregorianDate).toBeDefined() + expect(gregorianDate.year()).toBe(2024) + expect(gregorianDate.month()).toBe(0) // 0-based month (January) + expect(gregorianDate.date()).toBe(15) + }) + + it('should handle null input', () => { + expect(toGregorian(null)).toBeNull() + }) + }) + + describe('formatDate', () => { + it('should format date in Gregorian for non-Persian locale', () => { + moment.locale('en') + const date = new Date('2024-01-15') + const formatted = formatDate(date, 'LLL') + + expect(formatted).toContain('Jan 15, 2024') + }) + + it('should format date in Jalali for Persian locale', () => { + moment.locale('fa') + const date = new Date('2024-01-15') + const formatted = formatDate(date, 'LLL') + + expect(formatted).toContain('1402') + }) + }) + + describe('getRelativeTime', () => { + it('should return relative time in Gregorian for non-Persian locale', () => { + moment.locale('en') + const date = moment().subtract(1, 'day') + const relative = getRelativeTime(date) + + expect(relative).toContain('ago') + }) + + it('should return relative time in Jalali for Persian locale', () => { + moment.locale('fa') + const date = moment().subtract(1, 'day') + const relative = getRelativeTime(date) + + expect(relative).toContain('پیش') + }) + }) + + describe('Persian Calendar Data', () => { + it('should return correct Persian day names', () => { + const dayNames = getPersianDayNames() + expect(dayNames).toHaveLength(7) + expect(dayNames[0]).toBe('یکشنبه') // Sunday + expect(dayNames[6]).toBe('شنبه') // Saturday + }) + + it('should return correct Persian month names', () => { + const monthNames = getPersianMonthNames() + expect(monthNames).toHaveLength(12) + expect(monthNames[0]).toBe('فروردین') // Farvardin + expect(monthNames[11]).toBe('اسفند') // Esfand + }) + + it('should return correct Persian short day names', () => { + const shortDayNames = getPersianShortDayNames() + expect(shortDayNames).toHaveLength(7) + expect(shortDayNames[0]).toBe('ی') // Yek + expect(shortDayNames[6]).toBe('ش') // Shan + }) + + it('should return correct Persian short month names', () => { + const shortMonthNames = getPersianShortMonthNames() + expect(shortMonthNames).toHaveLength(12) + expect(shortMonthNames[0]).toBe('فر') // Far + expect(shortMonthNames[11]).toBe('اس') // Esf + }) + + it('should return correct Persian first day of week', () => { + expect(getPersianFirstDayOfWeek()).toBe(6) // Saturday + }) + }) + + describe('Jalali Date Operations', () => { + it('should check if date is Jalali today', () => { + const today = moment().jMoment() + expect(isJalaliToday(today.toDate())).toBe(true) + }) + + it('should check if date is Jalali tomorrow', () => { + const tomorrow = moment().add(1, 'day').jMoment() + expect(isJalaliTomorrow(tomorrow.toDate())).toBe(true) + }) + + it('should get Jalali components', () => { + const date = new Date('2024-01-15') + const components = getJalaliComponents(date) + + expect(components).toBeDefined() + expect(components.year).toBe(1402) + expect(components.month).toBe(11) // 1-based month + expect(components.day).toBe(25) + }) + + it('should create Jalali date from components', () => { + const jalaliDate = createJalaliDate(1402, 10, 25) + + expect(jalaliDate).toBeDefined() + expect(jalaliDate.jYear()).toBe(1402) + expect(jalaliDate.jMonth()).toBe(9) // 0-based month + expect(jalaliDate.jDate()).toBe(25) + }) + + it('should get current Jalali date', () => { + const currentJalali = getCurrentJalaliDate() + + expect(currentJalali).toBeDefined() + expect(currentJalali.jYear()).toBeGreaterThan(1400) + }) + }) + + describe('Jalali Date Formatting', () => { + it('should format Jalali date with default format', () => { + const date = new Date('2024-01-15') + const formatted = formatJalaliDate(date) + + expect(formatted).toContain('1402') + }) + + it('should format Jalali date with custom format', () => { + const date = new Date('2024-01-15') + const formatted = formatJalaliDate(date, 'jYYYY/jM/jD') + + expect(formatted).toMatch(/^\d{4}\/\d{1,2}\/\d{1,2}$/) + }) + + it('should get readable Jalali date', () => { + const date = new Date('2024-01-15') + const readable = getReadableJalaliDate(date) + + expect(readable).toBeDefined() + expect(typeof readable).toBe('string') + }) + }) +}) diff --git a/src/helpers/jalaliCalendar.js b/src/helpers/jalaliCalendar.js new file mode 100644 index 000000000..2dea64a38 --- /dev/null +++ b/src/helpers/jalaliCalendar.js @@ -0,0 +1,263 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import moment from '@nextcloud/moment' +import 'moment-jalaali' + +/** + * Jalali Calendar Utility for Persian/Shamsi calendar support + * Provides conversion between Gregorian and Jalali dates + */ + +/** + * Check if the current locale is Persian/Farsi + * @returns {boolean} + */ +export function isPersianLocale() { + const locale = moment.locale() + return locale === 'fa' || locale === 'fa-ir' || locale === 'persian' +} + +/** + * Get the current calendar type based on locale + * @returns {string} 'jalali' or 'gregorian' + */ +export function getCurrentCalendarType() { + return isPersianLocale() ? 'jalali' : 'gregorian' +} + +/** + * Convert a Gregorian date to Jalali + * @param {Date|string|moment} date - The date to convert + * @returns {moment} Jalali date as moment object + */ +export function toJalali(date) { + if (!date) return null + const momentDate = moment(date) + return momentDate.jMoment() +} + +/** + * Convert a Jalali date to Gregorian + * @param {Date|string|moment} jalaliDate - The Jalali date to convert + * @returns {moment} Gregorian date as moment object + */ +export function toGregorian(jalaliDate) { + if (!jalaliDate) return null + const momentDate = moment(jalaliDate) + return momentDate.jMoment().toMoment() +} + +/** + * Format a date according to the current calendar type + * @param {Date|string|moment} date - The date to format + * @param {string} format - The format string + * @returns {string} Formatted date string + */ +export function formatDate(date, format = 'LLL') { + if (!date) return '' + + const calendarType = getCurrentCalendarType() + + if (calendarType === 'jalali') { + const jalaliDate = toJalali(date) + return jalaliDate.format(format) + } + + return moment(date).format(format) +} + +/** + * Get relative time (e.g., "2 hours ago") in the current calendar + * @param {Date|string|moment} date - The date to get relative time for + * @returns {string} Relative time string + */ +export function getRelativeTime(date) { + if (!date) return '' + + const calendarType = getCurrentCalendarType() + + if (calendarType === 'jalali') { + const jalaliDate = toJalali(date) + return jalaliDate.fromNow() + } + + return moment(date).fromNow() +} + +/** + * Get day names in Persian for Jalali calendar + * @returns {Array} Array of Persian day names + */ +export function getPersianDayNames() { + return [ + 'یکشنبه', // Sunday + 'دوشنبه', // Monday + 'سه‌شنبه', // Tuesday + 'چهارشنبه', // Wednesday + 'پنج‌شنبه', // Thursday + 'جمعه', // Friday + 'شنبه' // Saturday + ] +} + +/** + * Get month names in Persian for Jalali calendar + * @returns {Array} Array of Persian month names + */ +export function getPersianMonthNames() { + return [ + 'فروردین', // Farvardin + 'اردیبهشت', // Ordibehesht + 'خرداد', // Khordad + 'تیر', // Tir + 'مرداد', // Mordad + 'شهریور', // Shahrivar + 'مهر', // Mehr + 'آبان', // Aban + 'آذر', // Azar + 'دی', // Dey + 'بهمن', // Bahman + 'اسفند' // Esfand + ] +} + +/** + * Get short month names in Persian for Jalali calendar + * @returns {Array} Array of short Persian month names + */ +export function getPersianShortMonthNames() { + return [ + 'فر', // Far + 'ارد', // Ord + 'خر', // Kho + 'تی', // Tir + 'مر', // Mor + 'شه', // Sha + 'مه', // Meh + 'آبا', // Aba + 'آذ', // Aza + 'دی', // Dey + 'به', // Bah + 'اس' // Esf + ] +} + +/** + * Get short day names in Persian for Jalali calendar + * @returns {Array} Array of short Persian day names + */ +export function getPersianShortDayNames() { + return [ + 'ی', // Yek (Sunday) + 'د', // Do (Monday) + 'س', // Se (Tuesday) + 'چ', // Chah (Wednesday) + 'پ', // Pan (Thursday) + 'ج', // Jom (Friday) + 'ش' // Shan (Saturday) + ] +} + +/** + * Get the first day of week for Persian calendar (Saturday = 6) + * @returns {number} First day of week (0-6, where 0 is Sunday) + */ +export function getPersianFirstDayOfWeek() { + return 6 // Saturday is the first day of week in Persian calendar +} + +/** + * Check if a date is today in Jalali calendar + * @param {Date|string|moment} date - The date to check + * @returns {boolean} True if the date is today + */ +export function isJalaliToday(date) { + if (!date) return false + const today = moment().jMoment() + const checkDate = moment(date).jMoment() + return today.isSame(checkDate, 'day') +} + +/** + * Check if a date is tomorrow in Jalali calendar + * @param {Date|string|moment} date - The date to check + * @returns {boolean} True if the date is tomorrow + */ +export function isJalaliTomorrow(date) { + if (!date) return false + const tomorrow = moment().add(1, 'day').jMoment() + const checkDate = moment(date).jMoment() + return tomorrow.isSame(checkDate, 'day') +} + +/** + * Get Jalali date components + * @param {Date|string|moment} date - The date to get components for + * @returns {Object} Object with jalali year, month, and day + */ +export function getJalaliComponents(date) { + if (!date) return null + const jalaliDate = toJalali(date) + return { + year: jalaliDate.jYear(), + month: jalaliDate.jMonth() + 1, // moment-jalaali months are 0-based + day: jalaliDate.jDate() + } +} + +/** + * Create a Jalali date from components + * @param {number} year - Jalali year + * @param {number} month - Jalali month (1-12) + * @param {number} day - Jalali day (1-31) + * @returns {moment} Jalali date as moment object + */ +export function createJalaliDate(year, month, day) { + return moment.jMoment(`${year}/${month}/${day}`, 'jYYYY/jM/jD') +} + +/** + * Get the current Jalali date + * @returns {moment} Current Jalali date + */ +export function getCurrentJalaliDate() { + return moment().jMoment() +} + +/** + * Format a Jalali date for display + * @param {Date|string|moment} date - The date to format + * @param {string} format - The format string (default: Persian long format) + * @returns {string} Formatted Jalali date string + */ +export function formatJalaliDate(date, format = 'jYYYY jMMMM jD') { + if (!date) return '' + const jalaliDate = toJalali(date) + return jalaliDate.format(format) +} + +/** + * Get a human-readable Jalali date string + * @param {Date|string|moment} date - The date to format + * @returns {string} Human-readable Jalali date string + */ +export function getReadableJalaliDate(date) { + if (!date) return '' + + const jalaliDate = toJalali(date) + const today = moment().jMoment() + const tomorrow = moment().add(1, 'day').jMoment() + + if (jalaliDate.isSame(today, 'day')) { + return 'امروز' + } else if (jalaliDate.isSame(tomorrow, 'day')) { + return 'فردا' + } else if (jalaliDate.isBefore(today, 'day')) { + return jalaliDate.fromNow() + } else { + return jalaliDate.fromNow() + } +} diff --git a/src/init-jalali-calendar.js b/src/init-jalali-calendar.js new file mode 100644 index 000000000..d37c0a53a --- /dev/null +++ b/src/init-jalali-calendar.js @@ -0,0 +1,144 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { subscribe } from '@nextcloud/event-bus' +import moment from '@nextcloud/moment' +import 'moment-jalaali' +import { + isPersianLocale, + getCurrentCalendarType, + getPersianDayNames, + getPersianMonthNames, + getPersianShortDayNames, + getPersianShortMonthNames, + getPersianFirstDayOfWeek +} from './helpers/jalaliCalendar.js' + +// Import the existing calendar initialization +import './init-calendar.js' + +/** + * Initialize Jalali calendar support + */ +function initJalaliCalendar() { + // Check if the current locale is Persian + if (isPersianLocale()) { + console.log('[deck] Initializing Jalali calendar support for Persian locale') + + // Set up Persian locale for moment.js + moment.locale('fa', { + months: getPersianMonthNames(), + monthsShort: getPersianShortMonthNames(), + weekdays: getPersianDayNames(), + weekdaysShort: getPersianShortDayNames(), + weekdaysMin: getPersianShortDayNames(), + weekdaysParseExact: true, + longDateFormat: { + LT: 'HH:mm', + LTS: 'HH:mm:ss', + L: 'jYYYY/jM/jD', + LL: 'jYYYY jMMMM jD', + LLL: 'jYYYY jMMMM jD HH:mm', + LLLL: 'dddd jYYYY jMMMM jD HH:mm' + }, + calendar: { + sameDay: '[امروز ساعت] LT', + nextDay: '[فردا ساعت] LT', + nextWeek: 'dddd [ساعت] LT', + lastDay: '[دیروز ساعت] LT', + lastWeek: 'dddd [ساعت] LT', + sameElse: 'L' + }, + relativeTime: { + future: 'در %s', + past: '%s پیش', + s: 'چند ثانیه', + ss: '%d ثانیه', + m: 'یک دقیقه', + mm: '%d دقیقه', + h: 'یک ساعت', + hh: '%d ساعت', + d: 'یک روز', + dd: '%d روز', + w: 'یک هفته', + ww: '%d هفته', + M: 'یک ماه', + MM: '%d ماه', + y: 'یک سال', + yy: '%d سال' + }, + ordinal: function (number) { + return number + }, + week: { + dow: getPersianFirstDayOfWeek(), // Saturday is the first day of week + doy: 7 // The week that contains Jan 1st is the first week of the year + } + }) + + // Set the current locale to Persian + moment.locale('fa') + + // Override Nextcloud's localization functions for Persian locale + if (window.OC && window.OC.L10N) { + // Store original functions + const originalGetDayNamesMin = window.OC.L10N.getDayNamesMin + const originalGetMonthNamesShort = window.OC.L10N.getMonthNamesShort + const originalGetFirstDay = window.OC.L10N.getFirstDay + + // Override day names for Persian locale + window.OC.L10N.getDayNamesMin = function() { + if (isPersianLocale()) { + return getPersianShortDayNames() + } + return originalGetDayNamesMin ? originalGetDayNamesMin() : [] + } + + // Override month names for Persian locale + window.OC.L10N.getMonthNamesShort = function() { + if (isPersianLocale()) { + return getPersianShortMonthNames() + } + return originalGetMonthNamesShort ? originalGetMonthNamesShort() : [] + } + + // Override first day of week for Persian locale + window.OC.L10N.getFirstDay = function() { + if (isPersianLocale()) { + return getPersianFirstDayOfWeek() + } + return originalGetFirstDay ? originalGetFirstDay() : 1 + } + } + } +} + +/** + * Subscribe to locale change events to reinitialize calendar + */ +subscribe('locale:changed', (locale) => { + console.log('[deck] Locale changed to:', locale) + // Reinitialize calendar support for the new locale + setTimeout(initJalaliCalendar, 100) +}) + +/** + * Initialize Jalali calendar support when the app loads + */ +document.addEventListener('DOMContentLoaded', () => { + initJalaliCalendar() +}) + +// Also initialize immediately if DOM is already loaded +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initJalaliCalendar) +} else { + initJalaliCalendar() +} + +/** + * Export calendar type for use in components + */ +export { getCurrentCalendarType, isPersianLocale } diff --git a/src/main.js b/src/main.js index 57e7a3aca..3ac502442 100644 --- a/src/main.js +++ b/src/main.js @@ -14,6 +14,7 @@ import ClickOutside from 'vue-click-outside' import './shared-init.js' import './models/index.js' import './sessions.js' +import './init-jalali-calendar.js' // the server snap.js conflicts with vertical scrolling so we disable it document.body.setAttribute('data-snap-ignore', 'true') diff --git a/src/mixins/enhancedReadableDate.js b/src/mixins/enhancedReadableDate.js new file mode 100644 index 000000000..b66e5338e --- /dev/null +++ b/src/mixins/enhancedReadableDate.js @@ -0,0 +1,159 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import moment from '@nextcloud/moment' +import { + formatDate, + getRelativeTime, + isPersianLocale, + getCurrentCalendarType, + formatJalaliDate, + getReadableJalaliDate +} from '../helpers/jalaliCalendar.js' + +export default { + computed: { + /** + * Enhanced date formatting that supports both Gregorian and Jalali calendars + * @param {Date|string|moment} timestamp - The timestamp to format + * @param {string} format - Optional format string + * @returns {string} Formatted date string + */ + formatEnhancedDate() { + return (timestamp, format = 'lll') => { + if (!timestamp) return '' + + const calendarType = getCurrentCalendarType() + + if (calendarType === 'jalali') { + return formatJalaliDate(timestamp, format) + } + + return moment(timestamp).format(format) + } + }, + + /** + * Enhanced readable date formatting with Jalali support + * @param {Date|string|moment} timestamp - The timestamp to format + * @returns {string} Readable date string + */ + formatReadableDate() { + return (timestamp) => { + if (!timestamp) return '' + + const calendarType = getCurrentCalendarType() + + if (calendarType === 'jalali') { + return getReadableJalaliDate(timestamp) + } + + return moment(timestamp).format('lll') + } + }, + + /** + * Enhanced relative date formatting with Jalali support + * @param {Date|string|moment} timestamp - The timestamp to format + * @returns {string} Relative date string + */ + formatRelativeDate() { + return (timestamp) => { + if (!timestamp) return '' + + const calendarType = getCurrentCalendarType() + + if (calendarType === 'jalali') { + return getRelativeTime(timestamp) + } + + return moment(timestamp).fromNow() + } + }, + + /** + * Check if the current locale uses Persian calendar + * @returns {boolean} + */ + isPersianCalendar() { + return isPersianLocale() + }, + + /** + * Get the current calendar type + * @returns {string} 'jalali' or 'gregorian' + */ + currentCalendarType() { + return getCurrentCalendarType() + }, + + /** + * Format date for display in the current calendar system + * @param {Date|string|moment} timestamp - The timestamp to format + * @returns {string} Formatted date string + */ + formatDateForDisplay() { + return (timestamp) => { + if (!timestamp) return '' + + const calendarType = getCurrentCalendarType() + + if (calendarType === 'jalali') { + // Use Persian format for Jalali calendar + return formatJalaliDate(timestamp, 'jYYYY jMMMM jD') + } + + // Use standard format for Gregorian calendar + return moment(timestamp).format('LLL') + } + }, + + /** + * Format time for display in the current calendar system + * @param {Date|string|moment} timestamp - The timestamp to format + * @returns {string} Formatted time string + */ + formatTimeForDisplay() { + return (timestamp) => { + if (!timestamp) return '' + + const calendarType = getCurrentCalendarType() + + if (calendarType === 'jalali') { + // For Jalali calendar, show both Jalali date and time + const jalaliDate = formatJalaliDate(timestamp, 'jYYYY/jM/jD') + const time = moment(timestamp).format('HH:mm') + return `${jalaliDate} ${time}` + } + + // For Gregorian calendar, show standard time + return moment(timestamp).format('LTS') + } + }, + + /** + * Format date and time for display in the current calendar system + * @param {Date|string|moment} timestamp - The timestamp to format + * @returns {string} Formatted date and time string + */ + formatDateTimeForDisplay() { + return (timestamp) => { + if (!timestamp) return '' + + const calendarType = getCurrentCalendarType() + + if (calendarType === 'jalali') { + // For Jalali calendar, show Jalali date with time + const jalaliDate = formatJalaliDate(timestamp, 'jYYYY jMMMM jD') + const time = moment(timestamp).format('HH:mm') + return `${jalaliDate} ساعت ${time}` + } + + // For Gregorian calendar, show standard date and time + return moment(timestamp).format('LLLL') + } + } + } +}