mirror of
https://github.com/nimbold/Firelink.git
synced 2026-07-26 12:08:27 +00:00
feat(settings): add opt-in calendar localization
This commit is contained in:
@@ -202,6 +202,21 @@ pub enum Theme {
|
||||
Nord,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
#[ts(export, export_to = "../../src/bindings/")]
|
||||
pub enum CalendarPreference {
|
||||
Gregorian,
|
||||
Persian,
|
||||
Hebrew,
|
||||
}
|
||||
|
||||
impl Default for CalendarPreference {
|
||||
fn default() -> Self {
|
||||
Self::Gregorian
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
#[ts(export, export_to = "../../src/bindings/")]
|
||||
@@ -267,6 +282,8 @@ pub struct SchedulerSettings {
|
||||
#[ts(export, export_to = "../../src/bindings/")]
|
||||
pub struct PersistedSettings {
|
||||
pub theme: Theme,
|
||||
#[serde(default)]
|
||||
pub calendar_preference: CalendarPreference,
|
||||
#[serde(default = "default_language_preference")]
|
||||
pub language: String,
|
||||
pub base_download_folder: String,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::ipc::{
|
||||
AppFontSize, ListRowDensity, MediaCookieSource, PersistedSettings, PostQueueAction, ProxyMode,
|
||||
SchedulerSettings, SettingsTab, Theme,
|
||||
AppFontSize, CalendarPreference, ListRowDensity, MediaCookieSource, PersistedSettings,
|
||||
PostQueueAction, ProxyMode, SchedulerSettings, SettingsTab, Theme,
|
||||
};
|
||||
use serde_json::{Map, Value};
|
||||
use std::collections::HashMap;
|
||||
@@ -197,6 +197,7 @@ fn sanitize_persisted_setting_values(state: &mut Value) {
|
||||
"language",
|
||||
&["system", "en", "zh-CN", "he", "fa", "uk", "ru"],
|
||||
);
|
||||
sanitize_allowed_string(state, "calendarPreference", &["gregorian", "persian", "hebrew"]);
|
||||
sanitize_allowed_string(state, "sidebarPosition", &["auto", "left", "right"]);
|
||||
sanitize_allowed_string(state, "appFontSize", &["small", "standard", "large"]);
|
||||
sanitize_allowed_string(state, "listRowDensity", &["compact", "standard", "relaxed"]);
|
||||
@@ -416,6 +417,7 @@ fn derived_location_path(base: &str, subfolder: &str) -> String {
|
||||
fn default_settings() -> PersistedSettings {
|
||||
PersistedSettings {
|
||||
theme: Theme::System,
|
||||
calendar_preference: CalendarPreference::Gregorian,
|
||||
language: "system".to_string(),
|
||||
base_download_folder: "~/Downloads".to_string(),
|
||||
category_subfolders_enabled: true,
|
||||
@@ -703,6 +705,7 @@ mod tests {
|
||||
"perServerConnections": 5,
|
||||
"showNotifications": "yes",
|
||||
"theme": "not-a-theme",
|
||||
"calendarPreference": "lunar",
|
||||
"siteLogins": [{"id": "valid", "urlPattern": "example.com", "username": "user"}, {"id": 3}]
|
||||
},
|
||||
"version": 3
|
||||
@@ -715,6 +718,10 @@ mod tests {
|
||||
assert_eq!(settings.per_server_connections, 5);
|
||||
assert!(settings.show_notifications);
|
||||
assert!(matches!(settings.theme, crate::ipc::Theme::System));
|
||||
assert!(matches!(
|
||||
settings.calendar_preference,
|
||||
crate::ipc::CalendarPreference::Gregorian
|
||||
));
|
||||
assert_eq!(settings.site_logins.len(), 1);
|
||||
assert_eq!(settings.site_logins[0].id, "valid");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type CalendarPreference = "gregorian" | "persian" | "hebrew";
|
||||
@@ -1,5 +1,6 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { AppFontSize } from "./AppFontSize";
|
||||
import type { CalendarPreference } from "./CalendarPreference";
|
||||
import type { ListRowDensity } from "./ListRowDensity";
|
||||
import type { MediaCookieSource } from "./MediaCookieSource";
|
||||
import type { ProxyMode } from "./ProxyMode";
|
||||
@@ -8,4 +9,4 @@ import type { SettingsTab } from "./SettingsTab";
|
||||
import type { SiteLogin } from "./SiteLogin";
|
||||
import type { Theme } from "./Theme";
|
||||
|
||||
export type PersistedSettings = { theme: Theme, language: string, baseDownloadFolder: string, categorySubfoldersEnabled: boolean, categorySubfolders: { [key in string]: string }, categoryDirectoryOverrides: { [key in string]: string }, approvedDownloadRoots: Array<string>, maxConcurrentDownloads: number, globalSpeedLimit: string, speedLimitPresetValues: Array<number>, logsEnabled: boolean, isSidebarVisible: boolean, sidebarPosition: string, activeSettingsTab: SettingsTab, scheduler: SchedulerSettings, schedulerRunning: boolean, schedulerActiveDownloadIds: Array<string>, schedulerLastStartKey: string, schedulerLastStopKey: string, lastCustomSpeedLimitKiB: number, lastCustomSpeedLimitUnit: string, perServerConnections: number, maxAutomaticRetries: number, showNotifications: boolean, playCompletionSound: boolean, autoAddClipboardLinks: boolean, appFontSize: AppFontSize, listRowDensity: ListRowDensity, showDockBadge: boolean, showMenuBarIcon: boolean, proxyMode: ProxyMode, proxyHost: string, proxyPort: number, customUserAgent: string, askWhereToSaveEachFile: boolean, rememberLastUsedDownloadDirectory: boolean, preventsSleepWhileDownloading: boolean, mediaCookieSource: MediaCookieSource, siteLogins: Array<SiteLogin>, autoCheckUpdates: boolean, keychainAccessGranted: boolean, };
|
||||
export type PersistedSettings = { theme: Theme, calendarPreference: CalendarPreference, language: string, baseDownloadFolder: string, categorySubfoldersEnabled: boolean, categorySubfolders: { [key in string]: string }, categoryDirectoryOverrides: { [key in string]: string }, approvedDownloadRoots: Array<string>, maxConcurrentDownloads: number, globalSpeedLimit: string, speedLimitPresetValues: Array<number>, logsEnabled: boolean, isSidebarVisible: boolean, sidebarPosition: string, activeSettingsTab: SettingsTab, scheduler: SchedulerSettings, schedulerRunning: boolean, schedulerActiveDownloadIds: Array<string>, schedulerLastStartKey: string, schedulerLastStopKey: string, lastCustomSpeedLimitKiB: number, lastCustomSpeedLimitUnit: string, perServerConnections: number, maxAutomaticRetries: number, showNotifications: boolean, playCompletionSound: boolean, autoAddClipboardLinks: boolean, appFontSize: AppFontSize, listRowDensity: ListRowDensity, showDockBadge: boolean, showMenuBarIcon: boolean, proxyMode: ProxyMode, proxyHost: string, proxyPort: number, customUserAgent: string, askWhereToSaveEachFile: boolean, rememberLastUsedDownloadDirectory: boolean, preventsSleepWhileDownloading: boolean, mediaCookieSource: MediaCookieSource, siteLogins: Array<SiteLogin>, autoCheckUpdates: boolean, keychainAccessGranted: boolean, };
|
||||
|
||||
@@ -4,6 +4,8 @@ import { Play, Pause, MoreVertical, Clock, ArrowUp, ArrowDown, GripVertical } fr
|
||||
import type { DownloadItem as DownloadItemType } from '../bindings/DownloadItem';
|
||||
import { canPauseDownload, canStartDownload } from '../utils/downloadActions';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSettingsStore } from '../store/useSettingsStore';
|
||||
import { formatDateTime } from '../utils/dateTime';
|
||||
import {
|
||||
downloadProgressColorClass,
|
||||
formatDownloadTotal,
|
||||
@@ -59,7 +61,8 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
||||
onQueueDragStart,
|
||||
onClick,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const calendarPreference = useSettingsStore(state => state.calendarPreference);
|
||||
const liveProgress = useDownloadProgressStore(state => state.progressMap[download.id]);
|
||||
const rowRef = React.useRef<HTMLDivElement>(null);
|
||||
const [isRowHovered, setIsRowHovered] = React.useState(false);
|
||||
@@ -71,6 +74,12 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
||||
const normalized = download.mediaQuality.replace(/[\u0000-\u001f\u007f]+/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
return normalized.length > 0 && normalized.length <= 48 ? normalized : undefined;
|
||||
})();
|
||||
const dateAddedLabel = download.dateAdded
|
||||
? formatDateTime(download.dateAdded, {
|
||||
locale: i18n.language,
|
||||
calendar: calendarPreference
|
||||
})
|
||||
: '-';
|
||||
|
||||
const updateActionPosition = React.useCallback(() => {
|
||||
const row = rowRef.current;
|
||||
@@ -327,9 +336,9 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
||||
<div className="download-column-cell download-cell-right download-column-date-added" style={columnStyle('Date Added')}>
|
||||
<span
|
||||
className="download-cell-content download-date-value tabular-nums"
|
||||
title={download.dateAdded ? new Date(download.dateAdded).toLocaleDateString() : '-'}
|
||||
title={dateAddedLabel}
|
||||
>
|
||||
{download.dateAdded ? new Date(download.dateAdded).toLocaleDateString() : '-'}
|
||||
{dateAddedLabel}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
|
||||
@@ -18,19 +18,25 @@ import {
|
||||
} from '../utils/downloadProgress';
|
||||
import { resolveDownloadConnections } from '../utils/downloads';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { formatDateTime, type CalendarPreference } from '../utils/dateTime';
|
||||
|
||||
type LoginMode = 'matching' | 'custom' | 'none';
|
||||
|
||||
const formatLastTry = (value?: string): string => {
|
||||
const formatLastTry = (
|
||||
value: string | undefined,
|
||||
locale: string,
|
||||
calendar: CalendarPreference
|
||||
): string => {
|
||||
if (!value) return '-';
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime())
|
||||
? '-'
|
||||
: date.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' });
|
||||
return formatDateTime(value, {
|
||||
locale,
|
||||
calendar,
|
||||
options: { dateStyle: 'medium', timeStyle: 'short' }
|
||||
});
|
||||
};
|
||||
|
||||
export const PropertiesModal = () => {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const categoryLabel = (category: string) => {
|
||||
switch (category) {
|
||||
case 'Musics': return t($ => $.navigation.categories.musics);
|
||||
@@ -55,7 +61,7 @@ export const PropertiesModal = () => {
|
||||
: undefined
|
||||
));
|
||||
|
||||
const { baseDownloadFolder, perServerConnections } = useSettingsStore();
|
||||
const { baseDownloadFolder, perServerConnections, calendarPreference } = useSettingsStore();
|
||||
|
||||
// Form states
|
||||
const [url, setUrl] = useState('');
|
||||
@@ -373,9 +379,9 @@ export const PropertiesModal = () => {
|
||||
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium shrink-0 whitespace-nowrap">{t($ => $.properties.connections)}</span><span className="text-text-secondary truncate whitespace-nowrap" title={item.connections !== undefined ? t($ => $.properties.savedTooltip) : t($ => $.properties.defaultTooltip)}><bdi>{connectionStatus}</bdi></span></div>
|
||||
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[60px] shrink-0">{t($ => $.properties.speedCap)}</span><span className="text-text-secondary truncate">{item.speedLimit || '-'}</span></div>
|
||||
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[55px] shrink-0">{t($ => $.properties.category)}</span><span className="text-text-secondary truncate">{categoryLabel(item.category)}</span></div>
|
||||
<div className="flex gap-1.5"><span className="text-text-muted font-medium w-[50px]">{t($ => $.properties.lastTry)}</span><span className="text-text-secondary truncate">{formatLastTry(item.lastTry)}</span></div>
|
||||
<div className="flex gap-1.5"><span className="text-text-muted font-medium w-[50px]">{t($ => $.properties.lastTry)}</span><span className="text-text-secondary truncate">{formatLastTry(item.lastTry, i18n.language, calendarPreference)}</span></div>
|
||||
|
||||
<div className="flex gap-1.5 col-span-2"><span className="text-text-muted font-medium w-[90px]">{t($ => $.properties.dateAdded)}</span><span className="text-text-secondary truncate">{new Date(item.dateAdded).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })}</span></div>
|
||||
<div className="flex gap-1.5 col-span-2"><span className="text-text-muted font-medium w-[90px]">{t($ => $.properties.dateAdded)}</span><span className="text-text-secondary truncate">{formatDateTime(item.dateAdded, { locale: i18n.language, calendar: calendarPreference, options: { dateStyle: 'medium', timeStyle: 'short' } })}</span></div>
|
||||
<div className="flex gap-1.5 col-span-2"><span className="text-text-muted font-medium w-[70px]">{t($ => $.properties.destination)}</span><span className="text-text-secondary truncate" title={saveLocation}>{saveLocation || baseDownloadFolder}</span></div>
|
||||
{item.lastError && (item.status === 'failed' || item.status === 'retrying') && (
|
||||
<div className="flex gap-1.5 col-span-4 min-w-0">
|
||||
|
||||
@@ -11,6 +11,7 @@ import { WindowDragRegion } from './WindowDragRegion';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import { usePlatformInfo } from '../utils/platform';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { formatDateTime } from '../utils/dateTime';
|
||||
|
||||
const days = [
|
||||
{ value: 0, key: 'su' },
|
||||
@@ -54,9 +55,10 @@ function nextScheduledRun(settings: SchedulerSettings): Date | 'disabled' | 'non
|
||||
}
|
||||
|
||||
export default function SchedulerView() {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const savedSettings = useSettingsStore(state => state.scheduler);
|
||||
const schedulerRunning = useSettingsStore(state => state.schedulerRunning);
|
||||
const calendarPreference = useSettingsStore(state => state.calendarPreference);
|
||||
const setScheduler = useSettingsStore(state => state.setScheduler);
|
||||
const queues = useDownloadStore(state => state.queues);
|
||||
const [draft, setDraft] = useState<SchedulerSettings>(savedSettings);
|
||||
@@ -75,14 +77,18 @@ export default function SchedulerView() {
|
||||
const scheduledRun = nextScheduledRun(draft);
|
||||
if (scheduledRun === 'disabled') return t($ => $.scheduler.disabled);
|
||||
if (scheduledRun === 'none') return t($ => $.scheduler.noScheduledDay);
|
||||
return scheduledRun.toLocaleString(undefined, {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit'
|
||||
return formatDateTime(scheduledRun, {
|
||||
locale: i18n.language,
|
||||
calendar: calendarPreference,
|
||||
options: {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit'
|
||||
}
|
||||
});
|
||||
}, [draft, t]);
|
||||
}, [calendarPreference, draft, i18n.language, t]);
|
||||
const hasUnsavedChanges = useMemo(
|
||||
() => JSON.stringify(draft) !== JSON.stringify(savedSettings),
|
||||
[draft, savedSettings]
|
||||
|
||||
@@ -693,6 +693,11 @@ runEngineChecks(false);
|
||||
{ value: 'uk', label: t($ => $.settings.lookAndFeel.languageUkrainian) },
|
||||
{ value: 'ru', label: t($ => $.settings.lookAndFeel.languageRussian) },
|
||||
] as const;
|
||||
const calendarOptions = [
|
||||
{ value: 'gregorian', label: t($ => $.settings.lookAndFeel.calendarGregorian) },
|
||||
{ value: 'persian', label: t($ => $.settings.lookAndFeel.calendarPersian) },
|
||||
{ value: 'hebrew', label: t($ => $.settings.lookAndFeel.calendarHebrew) },
|
||||
] as const;
|
||||
|
||||
const TabButton = ({ type, icon: Icon, label }: { type: SettingsTab; icon: typeof Download; label: string }) => {
|
||||
const active = activeTab === type;
|
||||
@@ -870,6 +875,21 @@ runEngineChecks(false);
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="mac-settings-row">
|
||||
<div className="settings-row-label">
|
||||
<span>{t($ => $.settings.lookAndFeel.calendar)}</span>
|
||||
<small>{t($ => $.settings.lookAndFeel.calendarDescription)}</small>
|
||||
</div>
|
||||
<select
|
||||
value={settings.calendarPreference}
|
||||
onChange={(event) => settings.setCalendarPreference(event.target.value as typeof settings.calendarPreference)}
|
||||
className="app-control w-48"
|
||||
>
|
||||
{calendarOptions.map(option => (
|
||||
<option key={option.value} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="mac-settings-row">
|
||||
<div className="settings-row-label">
|
||||
<span>{t($ => $.settings.lookAndFeel.sidebarPosition)}</span>
|
||||
|
||||
@@ -581,6 +581,11 @@ const common = {
|
||||
languagePersian: 'Persian',
|
||||
languageUkrainian: 'Ukrainian',
|
||||
languageRussian: 'Russian',
|
||||
calendar: 'Calendar',
|
||||
calendarDescription: 'Choose the calendar used for displayed dates and times. Gregorian is the default; this does not change scheduling or stored timestamps.',
|
||||
calendarGregorian: 'Gregorian',
|
||||
calendarPersian: 'Persian',
|
||||
calendarHebrew: 'Hebrew',
|
||||
appTheme: 'App Theme',
|
||||
theme: 'Theme',
|
||||
ariaLabel: 'App theme',
|
||||
|
||||
@@ -581,6 +581,11 @@ const fa = {
|
||||
languagePersian: 'فارسی',
|
||||
languageUkrainian: 'اوکراینی',
|
||||
languageRussian: 'روسی',
|
||||
calendar: 'تقویم',
|
||||
calendarDescription: 'تقویم مورد استفاده برای نمایش تاریخ و زمان را انتخاب کنید. تقویم میلادی پیشفرض است و زمانبندی یا مُهرهای زمانی ذخیرهشده را تغییر نمیدهد.',
|
||||
calendarGregorian: 'میلادی',
|
||||
calendarPersian: 'هجری شمسی',
|
||||
calendarHebrew: 'عبری',
|
||||
appTheme: 'ظاهر برنامه',
|
||||
theme: 'ظاهر',
|
||||
ariaLabel: 'ظاهر برنامه',
|
||||
|
||||
@@ -581,6 +581,11 @@ const he = {
|
||||
languagePersian: 'פרסית',
|
||||
languageUkrainian: 'אוקראינית',
|
||||
languageRussian: 'רוסית',
|
||||
calendar: 'לוח שנה',
|
||||
calendarDescription: 'בחר את לוח השנה לתצוגת תאריכים ושעות. הלוח הגרגוריאני הוא ברירת המחדל; הבחירה אינה משנה תזמון או חותמות זמן שמורות.',
|
||||
calendarGregorian: 'גרגוריאני',
|
||||
calendarPersian: 'פרסי',
|
||||
calendarHebrew: 'עברי',
|
||||
appTheme: 'ערכת נושא לאפליקציה',
|
||||
theme: 'ערכת נושא',
|
||||
ariaLabel: 'ערכת נושא לאפליקציה',
|
||||
|
||||
@@ -581,6 +581,11 @@ const ru = {
|
||||
languagePersian: 'Персидский',
|
||||
languageUkrainian: 'Украинский',
|
||||
languageRussian: 'Русский',
|
||||
calendar: 'Календарь',
|
||||
calendarDescription: 'Выберите календарь для отображения дат и времени. По умолчанию используется григорианский; расписание и сохранённые метки времени не изменяются.',
|
||||
calendarGregorian: 'Григорианский',
|
||||
calendarPersian: 'Персидский',
|
||||
calendarHebrew: 'Еврейский',
|
||||
appTheme: 'Тема приложения',
|
||||
theme: 'Тема',
|
||||
ariaLabel: 'Тема приложения',
|
||||
|
||||
@@ -581,6 +581,11 @@ const uk = {
|
||||
languagePersian: 'Перська',
|
||||
languageUkrainian: 'Українська',
|
||||
languageRussian: 'Російська',
|
||||
calendar: 'Календар',
|
||||
calendarDescription: 'Виберіть календар для відображення дат і часу. Типово використовується григоріанський; розклад і збережені мітки часу не змінюються.',
|
||||
calendarGregorian: 'Григоріанський',
|
||||
calendarPersian: 'Перський',
|
||||
calendarHebrew: 'Єврейський',
|
||||
appTheme: 'Тема програми',
|
||||
theme: 'Тема',
|
||||
ariaLabel: 'Тема програми',
|
||||
|
||||
@@ -581,6 +581,11 @@ const zhCN = {
|
||||
languagePersian: '波斯语',
|
||||
languageUkrainian: '乌克兰语',
|
||||
languageRussian: '俄语',
|
||||
calendar: '日历',
|
||||
calendarDescription: '选择显示日期和时间所使用的日历。默认使用公历;此选择不会改变计划或已保存的时间戳。',
|
||||
calendarGregorian: '公历',
|
||||
calendarPersian: '波斯历',
|
||||
calendarHebrew: '希伯来历',
|
||||
appTheme: '应用主题',
|
||||
theme: '主题',
|
||||
ariaLabel: '应用主题',
|
||||
|
||||
@@ -21,6 +21,24 @@ describe('last used download directory preference', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('calendar preference', () => {
|
||||
it('keeps Gregorian as the default and persists explicit calendar choices', async () => {
|
||||
vi.clearAllMocks();
|
||||
useSettingsStore.setState({ calendarPreference: 'gregorian' });
|
||||
expect(useSettingsStore.getState().calendarPreference).toBe('gregorian');
|
||||
|
||||
useSettingsStore.getState().setCalendarPreference('persian');
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
|
||||
expect(useSettingsStore.getState().calendarPreference).toBe('persian');
|
||||
const save = vi.mocked(ipc.invokeCommand).mock.calls
|
||||
.filter(([command]) => command === 'db_save_settings')
|
||||
.slice(-1)[0];
|
||||
expect(save).toBeDefined();
|
||||
expect(JSON.parse((save?.[1] as { data: string }).data).state.calendarPreference).toBe('persian');
|
||||
});
|
||||
});
|
||||
|
||||
describe('useSettingsStore global speed limit persistence', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
@@ -21,6 +21,11 @@ import {
|
||||
import { normalizeSpeedLimitForBackend } from '../utils/downloads';
|
||||
import i18n from '../i18n';
|
||||
import { isAppLocalePreference, type AppLocalePreference } from '../i18n/locales';
|
||||
import {
|
||||
DEFAULT_CALENDAR_PREFERENCE,
|
||||
isCalendarPreference,
|
||||
type CalendarPreference
|
||||
} from '../utils/dateTime';
|
||||
|
||||
let settingsQueue: Promise<void> = Promise.resolve();
|
||||
let pairingTokenHydrationRequest: Promise<PairingTokenHydration> | null = null;
|
||||
@@ -155,6 +160,7 @@ const tauriStorage: StateStorage = {
|
||||
export type {
|
||||
ActiveView,
|
||||
AppFontSize,
|
||||
CalendarPreference,
|
||||
ListRowDensity,
|
||||
MediaCookieSource,
|
||||
PostQueueAction,
|
||||
@@ -169,6 +175,7 @@ export type SidebarPosition = 'auto' | 'left' | 'right';
|
||||
|
||||
export interface SettingsState {
|
||||
theme: Theme;
|
||||
calendarPreference: CalendarPreference;
|
||||
language: AppLocalePreference;
|
||||
baseDownloadFolder: string;
|
||||
categorySubfoldersEnabled: boolean;
|
||||
@@ -224,6 +231,7 @@ export interface SettingsState {
|
||||
showKeychainModal: boolean;
|
||||
|
||||
setTheme: (theme: Theme) => void;
|
||||
setCalendarPreference: (calendarPreference: CalendarPreference) => void;
|
||||
setLanguage: (language: AppLocalePreference) => void;
|
||||
setBaseDownloadFolder: (path: string) => void;
|
||||
approveDownloadRoot: (path: string) => Promise<string>;
|
||||
@@ -280,6 +288,7 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
theme: 'system',
|
||||
calendarPreference: DEFAULT_CALENDAR_PREFERENCE,
|
||||
language: 'system',
|
||||
baseDownloadFolder: '~/Downloads',
|
||||
categorySubfoldersEnabled: true,
|
||||
@@ -342,6 +351,10 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
showKeychainModal: false,
|
||||
|
||||
setTheme: (theme) => { info('Settings updated: theme'); set({ theme }); },
|
||||
setCalendarPreference: (calendarPreference) => {
|
||||
info('Settings updated: calendarPreference');
|
||||
set({ calendarPreference });
|
||||
},
|
||||
setLanguage: (language) => { info('Settings updated: language'); set({ language }); },
|
||||
setBaseDownloadFolder: (path) => {
|
||||
info('Settings updated: baseDownloadFolder');
|
||||
@@ -512,7 +525,7 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
{
|
||||
name: 'firelink-settings',
|
||||
storage: createJSONStorage(() => tauriStorage),
|
||||
version: 5,
|
||||
version: 6,
|
||||
migrate: (persistedState) => {
|
||||
if (!persistedState || typeof persistedState !== 'object') {
|
||||
return persistedState as SettingsState;
|
||||
@@ -548,6 +561,7 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
},
|
||||
partialize: (state): PersistedSettingsSnapshot => ({
|
||||
theme: state.theme,
|
||||
calendarPreference: state.calendarPreference,
|
||||
language: state.language,
|
||||
baseDownloadFolder: state.baseDownloadFolder,
|
||||
categorySubfoldersEnabled: state.categorySubfoldersEnabled,
|
||||
@@ -608,6 +622,9 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
theme: isAllowedSetting(THEME_VALUES, persisted.theme)
|
||||
? persisted.theme
|
||||
: currentState.theme,
|
||||
calendarPreference: isCalendarPreference(persisted.calendarPreference)
|
||||
? persisted.calendarPreference
|
||||
: currentState.calendarPreference,
|
||||
language: isAppLocalePreference(persisted.language)
|
||||
? persisted.language
|
||||
: currentState.language,
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
DEFAULT_CALENDAR_PREFERENCE,
|
||||
formatDateTime,
|
||||
isCalendarPreference
|
||||
} from './dateTime';
|
||||
|
||||
describe('date/time formatting', () => {
|
||||
const instant = new Date('2026-03-21T14:30:00.000Z');
|
||||
|
||||
it('uses Gregorian explicitly by default, including in localized UI languages', () => {
|
||||
const options: Intl.DateTimeFormatOptions = { dateStyle: 'medium', timeStyle: 'short' };
|
||||
expect(formatDateTime(instant, { locale: 'fa', options })).toBe(
|
||||
new Intl.DateTimeFormat('fa-u-ca-gregory', options).format(instant)
|
||||
);
|
||||
expect(DEFAULT_CALENDAR_PREFERENCE).toBe('gregorian');
|
||||
});
|
||||
|
||||
it('supports the opt-in Persian and Hebrew calendars', () => {
|
||||
const options: Intl.DateTimeFormatOptions = { dateStyle: 'long' };
|
||||
expect(formatDateTime(instant, { locale: 'fa', calendar: 'persian', options })).toBe(
|
||||
new Intl.DateTimeFormat('fa-u-ca-persian', options).format(instant)
|
||||
);
|
||||
expect(formatDateTime(instant, { locale: 'he', calendar: 'hebrew', options })).toBe(
|
||||
new Intl.DateTimeFormat('he-u-ca-hebrew', options).format(instant)
|
||||
);
|
||||
});
|
||||
|
||||
it('returns a safe placeholder for malformed timestamps and rejects unknown preferences', () => {
|
||||
expect(formatDateTime('not-a-timestamp', { locale: 'en' })).toBe('-');
|
||||
expect(isCalendarPreference('gregorian')).toBe(true);
|
||||
expect(isCalendarPreference('lunar')).toBe(false);
|
||||
expect(isCalendarPreference(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { CalendarPreference } from '../bindings/CalendarPreference';
|
||||
import { resolveAppLocale } from '../i18n/locales';
|
||||
|
||||
export type { CalendarPreference } from '../bindings/CalendarPreference';
|
||||
|
||||
export const CALENDAR_PREFERENCES = ['gregorian', 'persian', 'hebrew'] as const satisfies readonly CalendarPreference[];
|
||||
export const DEFAULT_CALENDAR_PREFERENCE: CalendarPreference = 'gregorian';
|
||||
|
||||
export const isCalendarPreference = (value: unknown): value is CalendarPreference =>
|
||||
typeof value === 'string' && (CALENDAR_PREFERENCES as readonly string[]).includes(value);
|
||||
|
||||
type DateTimeInput = Date | string | number;
|
||||
|
||||
interface DateTimeFormatConfig {
|
||||
locale?: string | null;
|
||||
calendar?: CalendarPreference;
|
||||
options?: Intl.DateTimeFormatOptions;
|
||||
}
|
||||
|
||||
const calendarIdentifier = (calendar: CalendarPreference): string =>
|
||||
calendar === 'gregorian' ? 'gregory' : calendar;
|
||||
|
||||
const localeWithCalendar = (locale: string | null | undefined, calendar: CalendarPreference): string => {
|
||||
const resolvedLocale = resolveAppLocale(locale);
|
||||
return `${resolvedLocale}-u-ca-${calendarIdentifier(calendar)}`;
|
||||
};
|
||||
|
||||
const dateFromInput = (value: DateTimeInput): Date =>
|
||||
value instanceof Date ? new Date(value.getTime()) : new Date(value);
|
||||
|
||||
/**
|
||||
* Format a user-facing timestamp with an explicit calendar. Gregorian is
|
||||
* passed explicitly because some localized browser defaults use a regional
|
||||
* calendar even when the user has not opted into one.
|
||||
*/
|
||||
export const formatDateTime = (
|
||||
value: DateTimeInput,
|
||||
config: DateTimeFormatConfig = {}
|
||||
): string => {
|
||||
const date = dateFromInput(value);
|
||||
if (Number.isNaN(date.getTime())) return '-';
|
||||
|
||||
const calendar = config.calendar && isCalendarPreference(config.calendar)
|
||||
? config.calendar
|
||||
: DEFAULT_CALENDAR_PREFERENCE;
|
||||
const options = config.options ?? {};
|
||||
|
||||
try {
|
||||
return new Intl.DateTimeFormat(
|
||||
localeWithCalendar(config.locale, calendar),
|
||||
options
|
||||
).format(date);
|
||||
} catch {
|
||||
// WebViews can have incomplete ICU calendar data. Keep the display useful
|
||||
// and deterministic rather than exposing a RangeError to the UI.
|
||||
try {
|
||||
return new Intl.DateTimeFormat(
|
||||
localeWithCalendar(config.locale, DEFAULT_CALENDAR_PREFERENCE),
|
||||
options
|
||||
).format(date);
|
||||
} catch {
|
||||
return '-';
|
||||
}
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user