mirror of
https://github.com/nimbold/Firelink.git
synced 2026-09-03 14:37:56 +00:00
feat(settings): add opt-in calendar localization
This commit is contained in:
@@ -202,6 +202,21 @@ pub enum Theme {
|
|||||||
Nord,
|
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)]
|
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
#[ts(export, export_to = "../../src/bindings/")]
|
#[ts(export, export_to = "../../src/bindings/")]
|
||||||
@@ -267,6 +282,8 @@ pub struct SchedulerSettings {
|
|||||||
#[ts(export, export_to = "../../src/bindings/")]
|
#[ts(export, export_to = "../../src/bindings/")]
|
||||||
pub struct PersistedSettings {
|
pub struct PersistedSettings {
|
||||||
pub theme: Theme,
|
pub theme: Theme,
|
||||||
|
#[serde(default)]
|
||||||
|
pub calendar_preference: CalendarPreference,
|
||||||
#[serde(default = "default_language_preference")]
|
#[serde(default = "default_language_preference")]
|
||||||
pub language: String,
|
pub language: String,
|
||||||
pub base_download_folder: String,
|
pub base_download_folder: String,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use crate::ipc::{
|
use crate::ipc::{
|
||||||
AppFontSize, ListRowDensity, MediaCookieSource, PersistedSettings, PostQueueAction, ProxyMode,
|
AppFontSize, CalendarPreference, ListRowDensity, MediaCookieSource, PersistedSettings,
|
||||||
SchedulerSettings, SettingsTab, Theme,
|
PostQueueAction, ProxyMode, SchedulerSettings, SettingsTab, Theme,
|
||||||
};
|
};
|
||||||
use serde_json::{Map, Value};
|
use serde_json::{Map, Value};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
@@ -197,6 +197,7 @@ fn sanitize_persisted_setting_values(state: &mut Value) {
|
|||||||
"language",
|
"language",
|
||||||
&["system", "en", "zh-CN", "he", "fa", "uk", "ru"],
|
&["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, "sidebarPosition", &["auto", "left", "right"]);
|
||||||
sanitize_allowed_string(state, "appFontSize", &["small", "standard", "large"]);
|
sanitize_allowed_string(state, "appFontSize", &["small", "standard", "large"]);
|
||||||
sanitize_allowed_string(state, "listRowDensity", &["compact", "standard", "relaxed"]);
|
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 {
|
fn default_settings() -> PersistedSettings {
|
||||||
PersistedSettings {
|
PersistedSettings {
|
||||||
theme: Theme::System,
|
theme: Theme::System,
|
||||||
|
calendar_preference: CalendarPreference::Gregorian,
|
||||||
language: "system".to_string(),
|
language: "system".to_string(),
|
||||||
base_download_folder: "~/Downloads".to_string(),
|
base_download_folder: "~/Downloads".to_string(),
|
||||||
category_subfolders_enabled: true,
|
category_subfolders_enabled: true,
|
||||||
@@ -703,6 +705,7 @@ mod tests {
|
|||||||
"perServerConnections": 5,
|
"perServerConnections": 5,
|
||||||
"showNotifications": "yes",
|
"showNotifications": "yes",
|
||||||
"theme": "not-a-theme",
|
"theme": "not-a-theme",
|
||||||
|
"calendarPreference": "lunar",
|
||||||
"siteLogins": [{"id": "valid", "urlPattern": "example.com", "username": "user"}, {"id": 3}]
|
"siteLogins": [{"id": "valid", "urlPattern": "example.com", "username": "user"}, {"id": 3}]
|
||||||
},
|
},
|
||||||
"version": 3
|
"version": 3
|
||||||
@@ -715,6 +718,10 @@ mod tests {
|
|||||||
assert_eq!(settings.per_server_connections, 5);
|
assert_eq!(settings.per_server_connections, 5);
|
||||||
assert!(settings.show_notifications);
|
assert!(settings.show_notifications);
|
||||||
assert!(matches!(settings.theme, crate::ipc::Theme::System));
|
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.len(), 1);
|
||||||
assert_eq!(settings.site_logins[0].id, "valid");
|
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.
|
// 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 { AppFontSize } from "./AppFontSize";
|
||||||
|
import type { CalendarPreference } from "./CalendarPreference";
|
||||||
import type { ListRowDensity } from "./ListRowDensity";
|
import type { ListRowDensity } from "./ListRowDensity";
|
||||||
import type { MediaCookieSource } from "./MediaCookieSource";
|
import type { MediaCookieSource } from "./MediaCookieSource";
|
||||||
import type { ProxyMode } from "./ProxyMode";
|
import type { ProxyMode } from "./ProxyMode";
|
||||||
@@ -8,4 +9,4 @@ import type { SettingsTab } from "./SettingsTab";
|
|||||||
import type { SiteLogin } from "./SiteLogin";
|
import type { SiteLogin } from "./SiteLogin";
|
||||||
import type { Theme } from "./Theme";
|
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 type { DownloadItem as DownloadItemType } from '../bindings/DownloadItem';
|
||||||
import { canPauseDownload, canStartDownload } from '../utils/downloadActions';
|
import { canPauseDownload, canStartDownload } from '../utils/downloadActions';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useSettingsStore } from '../store/useSettingsStore';
|
||||||
|
import { formatDateTime } from '../utils/dateTime';
|
||||||
import {
|
import {
|
||||||
downloadProgressColorClass,
|
downloadProgressColorClass,
|
||||||
formatDownloadTotal,
|
formatDownloadTotal,
|
||||||
@@ -59,7 +61,8 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
|||||||
onQueueDragStart,
|
onQueueDragStart,
|
||||||
onClick,
|
onClick,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
|
const calendarPreference = useSettingsStore(state => state.calendarPreference);
|
||||||
const liveProgress = useDownloadProgressStore(state => state.progressMap[download.id]);
|
const liveProgress = useDownloadProgressStore(state => state.progressMap[download.id]);
|
||||||
const rowRef = React.useRef<HTMLDivElement>(null);
|
const rowRef = React.useRef<HTMLDivElement>(null);
|
||||||
const [isRowHovered, setIsRowHovered] = React.useState(false);
|
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();
|
const normalized = download.mediaQuality.replace(/[\u0000-\u001f\u007f]+/g, ' ').replace(/\s+/g, ' ').trim();
|
||||||
return normalized.length > 0 && normalized.length <= 48 ? normalized : undefined;
|
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 updateActionPosition = React.useCallback(() => {
|
||||||
const row = rowRef.current;
|
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')}>
|
<div className="download-column-cell download-cell-right download-column-date-added" style={columnStyle('Date Added')}>
|
||||||
<span
|
<span
|
||||||
className="download-cell-content download-date-value tabular-nums"
|
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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -18,19 +18,25 @@ import {
|
|||||||
} from '../utils/downloadProgress';
|
} from '../utils/downloadProgress';
|
||||||
import { resolveDownloadConnections } from '../utils/downloads';
|
import { resolveDownloadConnections } from '../utils/downloads';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { formatDateTime, type CalendarPreference } from '../utils/dateTime';
|
||||||
|
|
||||||
type LoginMode = 'matching' | 'custom' | 'none';
|
type LoginMode = 'matching' | 'custom' | 'none';
|
||||||
|
|
||||||
const formatLastTry = (value?: string): string => {
|
const formatLastTry = (
|
||||||
|
value: string | undefined,
|
||||||
|
locale: string,
|
||||||
|
calendar: CalendarPreference
|
||||||
|
): string => {
|
||||||
if (!value) return '-';
|
if (!value) return '-';
|
||||||
const date = new Date(value);
|
return formatDateTime(value, {
|
||||||
return Number.isNaN(date.getTime())
|
locale,
|
||||||
? '-'
|
calendar,
|
||||||
: date.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' });
|
options: { dateStyle: 'medium', timeStyle: 'short' }
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const PropertiesModal = () => {
|
export const PropertiesModal = () => {
|
||||||
const { t } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const categoryLabel = (category: string) => {
|
const categoryLabel = (category: string) => {
|
||||||
switch (category) {
|
switch (category) {
|
||||||
case 'Musics': return t($ => $.navigation.categories.musics);
|
case 'Musics': return t($ => $.navigation.categories.musics);
|
||||||
@@ -55,7 +61,7 @@ export const PropertiesModal = () => {
|
|||||||
: undefined
|
: undefined
|
||||||
));
|
));
|
||||||
|
|
||||||
const { baseDownloadFolder, perServerConnections } = useSettingsStore();
|
const { baseDownloadFolder, perServerConnections, calendarPreference } = useSettingsStore();
|
||||||
|
|
||||||
// Form states
|
// Form states
|
||||||
const [url, setUrl] = useState('');
|
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 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-[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 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>
|
<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') && (
|
{item.lastError && (item.status === 'failed' || item.status === 'retrying') && (
|
||||||
<div className="flex gap-1.5 col-span-4 min-w-0">
|
<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 { useToast } from '../contexts/ToastContext';
|
||||||
import { usePlatformInfo } from '../utils/platform';
|
import { usePlatformInfo } from '../utils/platform';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { formatDateTime } from '../utils/dateTime';
|
||||||
|
|
||||||
const days = [
|
const days = [
|
||||||
{ value: 0, key: 'su' },
|
{ value: 0, key: 'su' },
|
||||||
@@ -54,9 +55,10 @@ function nextScheduledRun(settings: SchedulerSettings): Date | 'disabled' | 'non
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function SchedulerView() {
|
export default function SchedulerView() {
|
||||||
const { t } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const savedSettings = useSettingsStore(state => state.scheduler);
|
const savedSettings = useSettingsStore(state => state.scheduler);
|
||||||
const schedulerRunning = useSettingsStore(state => state.schedulerRunning);
|
const schedulerRunning = useSettingsStore(state => state.schedulerRunning);
|
||||||
|
const calendarPreference = useSettingsStore(state => state.calendarPreference);
|
||||||
const setScheduler = useSettingsStore(state => state.setScheduler);
|
const setScheduler = useSettingsStore(state => state.setScheduler);
|
||||||
const queues = useDownloadStore(state => state.queues);
|
const queues = useDownloadStore(state => state.queues);
|
||||||
const [draft, setDraft] = useState<SchedulerSettings>(savedSettings);
|
const [draft, setDraft] = useState<SchedulerSettings>(savedSettings);
|
||||||
@@ -75,14 +77,18 @@ export default function SchedulerView() {
|
|||||||
const scheduledRun = nextScheduledRun(draft);
|
const scheduledRun = nextScheduledRun(draft);
|
||||||
if (scheduledRun === 'disabled') return t($ => $.scheduler.disabled);
|
if (scheduledRun === 'disabled') return t($ => $.scheduler.disabled);
|
||||||
if (scheduledRun === 'none') return t($ => $.scheduler.noScheduledDay);
|
if (scheduledRun === 'none') return t($ => $.scheduler.noScheduledDay);
|
||||||
return scheduledRun.toLocaleString(undefined, {
|
return formatDateTime(scheduledRun, {
|
||||||
weekday: 'short',
|
locale: i18n.language,
|
||||||
month: 'short',
|
calendar: calendarPreference,
|
||||||
day: 'numeric',
|
options: {
|
||||||
hour: 'numeric',
|
weekday: 'short',
|
||||||
minute: '2-digit'
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit'
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}, [draft, t]);
|
}, [calendarPreference, draft, i18n.language, t]);
|
||||||
const hasUnsavedChanges = useMemo(
|
const hasUnsavedChanges = useMemo(
|
||||||
() => JSON.stringify(draft) !== JSON.stringify(savedSettings),
|
() => JSON.stringify(draft) !== JSON.stringify(savedSettings),
|
||||||
[draft, savedSettings]
|
[draft, savedSettings]
|
||||||
|
|||||||
@@ -693,6 +693,11 @@ runEngineChecks(false);
|
|||||||
{ value: 'uk', label: t($ => $.settings.lookAndFeel.languageUkrainian) },
|
{ value: 'uk', label: t($ => $.settings.lookAndFeel.languageUkrainian) },
|
||||||
{ value: 'ru', label: t($ => $.settings.lookAndFeel.languageRussian) },
|
{ value: 'ru', label: t($ => $.settings.lookAndFeel.languageRussian) },
|
||||||
] as const;
|
] 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 TabButton = ({ type, icon: Icon, label }: { type: SettingsTab; icon: typeof Download; label: string }) => {
|
||||||
const active = activeTab === type;
|
const active = activeTab === type;
|
||||||
@@ -870,6 +875,21 @@ runEngineChecks(false);
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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="mac-settings-row">
|
||||||
<div className="settings-row-label">
|
<div className="settings-row-label">
|
||||||
<span>{t($ => $.settings.lookAndFeel.sidebarPosition)}</span>
|
<span>{t($ => $.settings.lookAndFeel.sidebarPosition)}</span>
|
||||||
|
|||||||
@@ -581,6 +581,11 @@ const common = {
|
|||||||
languagePersian: 'Persian',
|
languagePersian: 'Persian',
|
||||||
languageUkrainian: 'Ukrainian',
|
languageUkrainian: 'Ukrainian',
|
||||||
languageRussian: 'Russian',
|
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',
|
appTheme: 'App Theme',
|
||||||
theme: 'Theme',
|
theme: 'Theme',
|
||||||
ariaLabel: 'App theme',
|
ariaLabel: 'App theme',
|
||||||
|
|||||||
@@ -581,6 +581,11 @@ const fa = {
|
|||||||
languagePersian: 'فارسی',
|
languagePersian: 'فارسی',
|
||||||
languageUkrainian: 'اوکراینی',
|
languageUkrainian: 'اوکراینی',
|
||||||
languageRussian: 'روسی',
|
languageRussian: 'روسی',
|
||||||
|
calendar: 'تقویم',
|
||||||
|
calendarDescription: 'تقویم مورد استفاده برای نمایش تاریخ و زمان را انتخاب کنید. تقویم میلادی پیشفرض است و زمانبندی یا مُهرهای زمانی ذخیرهشده را تغییر نمیدهد.',
|
||||||
|
calendarGregorian: 'میلادی',
|
||||||
|
calendarPersian: 'هجری شمسی',
|
||||||
|
calendarHebrew: 'عبری',
|
||||||
appTheme: 'ظاهر برنامه',
|
appTheme: 'ظاهر برنامه',
|
||||||
theme: 'ظاهر',
|
theme: 'ظاهر',
|
||||||
ariaLabel: 'ظاهر برنامه',
|
ariaLabel: 'ظاهر برنامه',
|
||||||
|
|||||||
@@ -581,6 +581,11 @@ const he = {
|
|||||||
languagePersian: 'פרסית',
|
languagePersian: 'פרסית',
|
||||||
languageUkrainian: 'אוקראינית',
|
languageUkrainian: 'אוקראינית',
|
||||||
languageRussian: 'רוסית',
|
languageRussian: 'רוסית',
|
||||||
|
calendar: 'לוח שנה',
|
||||||
|
calendarDescription: 'בחר את לוח השנה לתצוגת תאריכים ושעות. הלוח הגרגוריאני הוא ברירת המחדל; הבחירה אינה משנה תזמון או חותמות זמן שמורות.',
|
||||||
|
calendarGregorian: 'גרגוריאני',
|
||||||
|
calendarPersian: 'פרסי',
|
||||||
|
calendarHebrew: 'עברי',
|
||||||
appTheme: 'ערכת נושא לאפליקציה',
|
appTheme: 'ערכת נושא לאפליקציה',
|
||||||
theme: 'ערכת נושא',
|
theme: 'ערכת נושא',
|
||||||
ariaLabel: 'ערכת נושא לאפליקציה',
|
ariaLabel: 'ערכת נושא לאפליקציה',
|
||||||
|
|||||||
@@ -581,6 +581,11 @@ const ru = {
|
|||||||
languagePersian: 'Персидский',
|
languagePersian: 'Персидский',
|
||||||
languageUkrainian: 'Украинский',
|
languageUkrainian: 'Украинский',
|
||||||
languageRussian: 'Русский',
|
languageRussian: 'Русский',
|
||||||
|
calendar: 'Календарь',
|
||||||
|
calendarDescription: 'Выберите календарь для отображения дат и времени. По умолчанию используется григорианский; расписание и сохранённые метки времени не изменяются.',
|
||||||
|
calendarGregorian: 'Григорианский',
|
||||||
|
calendarPersian: 'Персидский',
|
||||||
|
calendarHebrew: 'Еврейский',
|
||||||
appTheme: 'Тема приложения',
|
appTheme: 'Тема приложения',
|
||||||
theme: 'Тема',
|
theme: 'Тема',
|
||||||
ariaLabel: 'Тема приложения',
|
ariaLabel: 'Тема приложения',
|
||||||
|
|||||||
@@ -581,6 +581,11 @@ const uk = {
|
|||||||
languagePersian: 'Перська',
|
languagePersian: 'Перська',
|
||||||
languageUkrainian: 'Українська',
|
languageUkrainian: 'Українська',
|
||||||
languageRussian: 'Російська',
|
languageRussian: 'Російська',
|
||||||
|
calendar: 'Календар',
|
||||||
|
calendarDescription: 'Виберіть календар для відображення дат і часу. Типово використовується григоріанський; розклад і збережені мітки часу не змінюються.',
|
||||||
|
calendarGregorian: 'Григоріанський',
|
||||||
|
calendarPersian: 'Перський',
|
||||||
|
calendarHebrew: 'Єврейський',
|
||||||
appTheme: 'Тема програми',
|
appTheme: 'Тема програми',
|
||||||
theme: 'Тема',
|
theme: 'Тема',
|
||||||
ariaLabel: 'Тема програми',
|
ariaLabel: 'Тема програми',
|
||||||
|
|||||||
@@ -581,6 +581,11 @@ const zhCN = {
|
|||||||
languagePersian: '波斯语',
|
languagePersian: '波斯语',
|
||||||
languageUkrainian: '乌克兰语',
|
languageUkrainian: '乌克兰语',
|
||||||
languageRussian: '俄语',
|
languageRussian: '俄语',
|
||||||
|
calendar: '日历',
|
||||||
|
calendarDescription: '选择显示日期和时间所使用的日历。默认使用公历;此选择不会改变计划或已保存的时间戳。',
|
||||||
|
calendarGregorian: '公历',
|
||||||
|
calendarPersian: '波斯历',
|
||||||
|
calendarHebrew: '希伯来历',
|
||||||
appTheme: '应用主题',
|
appTheme: '应用主题',
|
||||||
theme: '主题',
|
theme: '主题',
|
||||||
ariaLabel: '应用主题',
|
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', () => {
|
describe('useSettingsStore global speed limit persistence', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
|||||||
@@ -21,6 +21,11 @@ import {
|
|||||||
import { normalizeSpeedLimitForBackend } from '../utils/downloads';
|
import { normalizeSpeedLimitForBackend } from '../utils/downloads';
|
||||||
import i18n from '../i18n';
|
import i18n from '../i18n';
|
||||||
import { isAppLocalePreference, type AppLocalePreference } from '../i18n/locales';
|
import { isAppLocalePreference, type AppLocalePreference } from '../i18n/locales';
|
||||||
|
import {
|
||||||
|
DEFAULT_CALENDAR_PREFERENCE,
|
||||||
|
isCalendarPreference,
|
||||||
|
type CalendarPreference
|
||||||
|
} from '../utils/dateTime';
|
||||||
|
|
||||||
let settingsQueue: Promise<void> = Promise.resolve();
|
let settingsQueue: Promise<void> = Promise.resolve();
|
||||||
let pairingTokenHydrationRequest: Promise<PairingTokenHydration> | null = null;
|
let pairingTokenHydrationRequest: Promise<PairingTokenHydration> | null = null;
|
||||||
@@ -155,6 +160,7 @@ const tauriStorage: StateStorage = {
|
|||||||
export type {
|
export type {
|
||||||
ActiveView,
|
ActiveView,
|
||||||
AppFontSize,
|
AppFontSize,
|
||||||
|
CalendarPreference,
|
||||||
ListRowDensity,
|
ListRowDensity,
|
||||||
MediaCookieSource,
|
MediaCookieSource,
|
||||||
PostQueueAction,
|
PostQueueAction,
|
||||||
@@ -169,6 +175,7 @@ export type SidebarPosition = 'auto' | 'left' | 'right';
|
|||||||
|
|
||||||
export interface SettingsState {
|
export interface SettingsState {
|
||||||
theme: Theme;
|
theme: Theme;
|
||||||
|
calendarPreference: CalendarPreference;
|
||||||
language: AppLocalePreference;
|
language: AppLocalePreference;
|
||||||
baseDownloadFolder: string;
|
baseDownloadFolder: string;
|
||||||
categorySubfoldersEnabled: boolean;
|
categorySubfoldersEnabled: boolean;
|
||||||
@@ -224,6 +231,7 @@ export interface SettingsState {
|
|||||||
showKeychainModal: boolean;
|
showKeychainModal: boolean;
|
||||||
|
|
||||||
setTheme: (theme: Theme) => void;
|
setTheme: (theme: Theme) => void;
|
||||||
|
setCalendarPreference: (calendarPreference: CalendarPreference) => void;
|
||||||
setLanguage: (language: AppLocalePreference) => void;
|
setLanguage: (language: AppLocalePreference) => void;
|
||||||
setBaseDownloadFolder: (path: string) => void;
|
setBaseDownloadFolder: (path: string) => void;
|
||||||
approveDownloadRoot: (path: string) => Promise<string>;
|
approveDownloadRoot: (path: string) => Promise<string>;
|
||||||
@@ -280,6 +288,7 @@ export const useSettingsStore = create<SettingsState>()(
|
|||||||
persist(
|
persist(
|
||||||
(set, get) => ({
|
(set, get) => ({
|
||||||
theme: 'system',
|
theme: 'system',
|
||||||
|
calendarPreference: DEFAULT_CALENDAR_PREFERENCE,
|
||||||
language: 'system',
|
language: 'system',
|
||||||
baseDownloadFolder: '~/Downloads',
|
baseDownloadFolder: '~/Downloads',
|
||||||
categorySubfoldersEnabled: true,
|
categorySubfoldersEnabled: true,
|
||||||
@@ -342,6 +351,10 @@ export const useSettingsStore = create<SettingsState>()(
|
|||||||
showKeychainModal: false,
|
showKeychainModal: false,
|
||||||
|
|
||||||
setTheme: (theme) => { info('Settings updated: theme'); set({ theme }); },
|
setTheme: (theme) => { info('Settings updated: theme'); set({ theme }); },
|
||||||
|
setCalendarPreference: (calendarPreference) => {
|
||||||
|
info('Settings updated: calendarPreference');
|
||||||
|
set({ calendarPreference });
|
||||||
|
},
|
||||||
setLanguage: (language) => { info('Settings updated: language'); set({ language }); },
|
setLanguage: (language) => { info('Settings updated: language'); set({ language }); },
|
||||||
setBaseDownloadFolder: (path) => {
|
setBaseDownloadFolder: (path) => {
|
||||||
info('Settings updated: baseDownloadFolder');
|
info('Settings updated: baseDownloadFolder');
|
||||||
@@ -512,7 +525,7 @@ export const useSettingsStore = create<SettingsState>()(
|
|||||||
{
|
{
|
||||||
name: 'firelink-settings',
|
name: 'firelink-settings',
|
||||||
storage: createJSONStorage(() => tauriStorage),
|
storage: createJSONStorage(() => tauriStorage),
|
||||||
version: 5,
|
version: 6,
|
||||||
migrate: (persistedState) => {
|
migrate: (persistedState) => {
|
||||||
if (!persistedState || typeof persistedState !== 'object') {
|
if (!persistedState || typeof persistedState !== 'object') {
|
||||||
return persistedState as SettingsState;
|
return persistedState as SettingsState;
|
||||||
@@ -548,6 +561,7 @@ export const useSettingsStore = create<SettingsState>()(
|
|||||||
},
|
},
|
||||||
partialize: (state): PersistedSettingsSnapshot => ({
|
partialize: (state): PersistedSettingsSnapshot => ({
|
||||||
theme: state.theme,
|
theme: state.theme,
|
||||||
|
calendarPreference: state.calendarPreference,
|
||||||
language: state.language,
|
language: state.language,
|
||||||
baseDownloadFolder: state.baseDownloadFolder,
|
baseDownloadFolder: state.baseDownloadFolder,
|
||||||
categorySubfoldersEnabled: state.categorySubfoldersEnabled,
|
categorySubfoldersEnabled: state.categorySubfoldersEnabled,
|
||||||
@@ -608,6 +622,9 @@ export const useSettingsStore = create<SettingsState>()(
|
|||||||
theme: isAllowedSetting(THEME_VALUES, persisted.theme)
|
theme: isAllowedSetting(THEME_VALUES, persisted.theme)
|
||||||
? persisted.theme
|
? persisted.theme
|
||||||
: currentState.theme,
|
: currentState.theme,
|
||||||
|
calendarPreference: isCalendarPreference(persisted.calendarPreference)
|
||||||
|
? persisted.calendarPreference
|
||||||
|
: currentState.calendarPreference,
|
||||||
language: isAppLocalePreference(persisted.language)
|
language: isAppLocalePreference(persisted.language)
|
||||||
? persisted.language
|
? persisted.language
|
||||||
: currentState.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