Initial import
This commit is contained in:
654
src/app/main/appConfig.ts
Executable file
654
src/app/main/appConfig.ts
Executable file
@@ -0,0 +1,654 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
import { appendClientIdHeader } from './clientIdentity';
|
||||
import { getAutomationNotificationPreferenceTarget } from './notificationIdentity';
|
||||
|
||||
const APP_CONFIG_STORAGE_KEY = 'work-server.app-config';
|
||||
const APP_CONFIG_EVENT = 'work-server:app-config';
|
||||
const APP_CONFIG_API_PATH = '/app-config';
|
||||
const AUTOMATION_NOTIFICATION_PREFERENCE_API_PATH = '/notifications/preferences/automation';
|
||||
const APP_CONFIG_REQUEST_TIMEOUT_MS = 8000;
|
||||
let cachedConfig: AppConfig | null = null;
|
||||
let cachedRawConfig: string | null = null;
|
||||
|
||||
export type AutomationScheduleType = 'interval' | 'daily' | 'weekly';
|
||||
export type WeeklyScheduleDay = 'mon' | 'tue' | 'wed' | 'thu' | 'fri' | 'sat' | 'sun';
|
||||
export type PlanCostTimeUnit = 'hour' | 'minute' | 'second';
|
||||
|
||||
export type AppConfig = {
|
||||
chat: {
|
||||
maxContextMessages: number;
|
||||
maxContextChars: number;
|
||||
};
|
||||
automation: {
|
||||
autoRefreshEnabled: boolean;
|
||||
autoRefreshIntervalSeconds: number;
|
||||
autoReceiveScheduleType: AutomationScheduleType;
|
||||
autoReceiveIntervalSeconds: number;
|
||||
autoReceiveDailyTime: string;
|
||||
autoReceiveWeeklyDay: WeeklyScheduleDay;
|
||||
autoReceiveWeeklyTime: string;
|
||||
notifyOnAutomationStart: boolean;
|
||||
notifyOnAutomationProgress: boolean;
|
||||
notifyOnAutomationCompletion: boolean;
|
||||
notifyOnAutomationRelease: boolean;
|
||||
notifyOnAutomationMain: boolean;
|
||||
notifyOnAutomationFailure: boolean;
|
||||
notifyOnAutomationRestart: boolean;
|
||||
notifyOnAutomationIssueResolved: boolean;
|
||||
};
|
||||
worklogAutomation: {
|
||||
autoCreateDailyWorklog: boolean;
|
||||
dailyCreateTime: string;
|
||||
repeatRequestEnabled: boolean;
|
||||
repeatIntervalMinutes: number;
|
||||
includeScreenshots: boolean;
|
||||
includeChangedFiles: boolean;
|
||||
includeCommandLogs: boolean;
|
||||
template: 'simple' | 'detailed';
|
||||
};
|
||||
planDefaults: {
|
||||
jangsingProcessingRequired: boolean;
|
||||
autoDeployToMain: boolean;
|
||||
openEditorAfterCreate: boolean;
|
||||
};
|
||||
planCost: {
|
||||
baseCostPerMillionTokens: number;
|
||||
retryCostMultiplierPercent: number;
|
||||
hourlyCostMultiplierPercent: number;
|
||||
timeCostUnit: PlanCostTimeUnit;
|
||||
attentionCostThresholdMultiplier: number;
|
||||
warningCostThresholdMultiplier: number;
|
||||
highCostThresholdMultiplier: number;
|
||||
};
|
||||
gestureShortcuts: {
|
||||
openSearch: string;
|
||||
openWindowSearch: string;
|
||||
};
|
||||
};
|
||||
|
||||
export const DEFAULT_APP_CONFIG: AppConfig = {
|
||||
chat: {
|
||||
maxContextMessages: 12,
|
||||
maxContextChars: 3200,
|
||||
},
|
||||
automation: {
|
||||
autoRefreshEnabled: true,
|
||||
autoRefreshIntervalSeconds: 5,
|
||||
autoReceiveScheduleType: 'interval',
|
||||
autoReceiveIntervalSeconds: 30,
|
||||
autoReceiveDailyTime: '09:00',
|
||||
autoReceiveWeeklyDay: 'mon',
|
||||
autoReceiveWeeklyTime: '09:00',
|
||||
notifyOnAutomationStart: true,
|
||||
notifyOnAutomationProgress: true,
|
||||
notifyOnAutomationCompletion: true,
|
||||
notifyOnAutomationRelease: true,
|
||||
notifyOnAutomationMain: true,
|
||||
notifyOnAutomationFailure: true,
|
||||
notifyOnAutomationRestart: true,
|
||||
notifyOnAutomationIssueResolved: true,
|
||||
},
|
||||
worklogAutomation: {
|
||||
autoCreateDailyWorklog: false,
|
||||
dailyCreateTime: '18:00',
|
||||
repeatRequestEnabled: false,
|
||||
repeatIntervalMinutes: 60,
|
||||
includeScreenshots: true,
|
||||
includeChangedFiles: true,
|
||||
includeCommandLogs: true,
|
||||
template: 'detailed',
|
||||
},
|
||||
planDefaults: {
|
||||
jangsingProcessingRequired: true,
|
||||
autoDeployToMain: true,
|
||||
openEditorAfterCreate: true,
|
||||
},
|
||||
planCost: {
|
||||
baseCostPerMillionTokens: 10000,
|
||||
retryCostMultiplierPercent: 15,
|
||||
hourlyCostMultiplierPercent: 0,
|
||||
timeCostUnit: 'hour',
|
||||
attentionCostThresholdMultiplier: 1,
|
||||
warningCostThresholdMultiplier: 2,
|
||||
highCostThresholdMultiplier: 4,
|
||||
},
|
||||
gestureShortcuts: {
|
||||
openSearch: 'Mod+K',
|
||||
openWindowSearch: 'Mod+Shift+K',
|
||||
},
|
||||
};
|
||||
|
||||
const WEEKLY_DAY_LABELS: Record<WeeklyScheduleDay, string> = {
|
||||
mon: '월요일',
|
||||
tue: '화요일',
|
||||
wed: '수요일',
|
||||
thu: '목요일',
|
||||
fri: '금요일',
|
||||
sat: '토요일',
|
||||
sun: '일요일',
|
||||
};
|
||||
|
||||
const AUTOMATION_NOTIFICATION_KEYS = [
|
||||
'notifyOnAutomationStart',
|
||||
'notifyOnAutomationProgress',
|
||||
'notifyOnAutomationCompletion',
|
||||
'notifyOnAutomationRelease',
|
||||
'notifyOnAutomationMain',
|
||||
'notifyOnAutomationFailure',
|
||||
'notifyOnAutomationRestart',
|
||||
'notifyOnAutomationIssueResolved',
|
||||
] as const;
|
||||
|
||||
type AutomationNotificationSettings = Pick<AppConfig['automation'], (typeof AUTOMATION_NOTIFICATION_KEYS)[number]>;
|
||||
|
||||
function clampIntervalSeconds(value: number, fallback: number) {
|
||||
if (!Number.isFinite(value)) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return Math.min(3600, Math.max(1, Math.round(value)));
|
||||
}
|
||||
|
||||
function normalizeTimeValue(value: string, fallback: string) {
|
||||
if (/^\d{2}:\d{2}$/.test(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function normalizeScheduleType(value: string | undefined): AutomationScheduleType {
|
||||
if (value === 'daily' || value === 'weekly') {
|
||||
return value;
|
||||
}
|
||||
|
||||
return 'interval';
|
||||
}
|
||||
|
||||
function normalizeWeeklyDay(value: string | undefined): WeeklyScheduleDay {
|
||||
if (value && value in WEEKLY_DAY_LABELS) {
|
||||
return value as WeeklyScheduleDay;
|
||||
}
|
||||
|
||||
return DEFAULT_APP_CONFIG.automation.autoReceiveWeeklyDay;
|
||||
}
|
||||
|
||||
function normalizeWorklogTemplate(value: string | undefined): AppConfig['worklogAutomation']['template'] {
|
||||
if (value === 'simple') {
|
||||
return 'simple';
|
||||
}
|
||||
|
||||
return 'detailed';
|
||||
}
|
||||
|
||||
function normalizeShortcutValue(value: string | undefined, fallback: string) {
|
||||
const trimmed = value?.trim();
|
||||
|
||||
if (!trimmed) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return trimmed
|
||||
.split('+')
|
||||
.map((token) => token.trim())
|
||||
.filter(Boolean)
|
||||
.join('+');
|
||||
}
|
||||
|
||||
function normalizePlanCostValue(value: number | undefined, fallback: number) {
|
||||
if (value === undefined || !Number.isFinite(value)) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return Math.min(1_000_000, Math.max(100, Math.round(value)));
|
||||
}
|
||||
|
||||
function normalizePlanCostMultiplierValue(value: number | undefined, fallback: number) {
|
||||
if (value === undefined || !Number.isFinite(value)) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return Math.min(100, Math.max(0.1, Math.round(value * 10) / 10));
|
||||
}
|
||||
|
||||
function normalizePlanCostPercentValue(value: number | undefined, fallback: number) {
|
||||
if (value === undefined || !Number.isFinite(value)) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return Math.min(500, Math.max(0, Math.round(value)));
|
||||
}
|
||||
|
||||
function normalizePlanCostTimeUnit(value: string | undefined): PlanCostTimeUnit {
|
||||
if (value === 'minute' || value === 'second') {
|
||||
return value;
|
||||
}
|
||||
|
||||
return 'hour';
|
||||
}
|
||||
|
||||
function normalizeChatContextMessageLimit(value: number | undefined, fallback: number) {
|
||||
if (value === undefined || !Number.isFinite(value)) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return Math.min(50, Math.max(1, Math.round(value)));
|
||||
}
|
||||
|
||||
function normalizeChatContextCharLimit(value: number | undefined, fallback: number) {
|
||||
if (value === undefined || !Number.isFinite(value)) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return Math.min(20_000, Math.max(500, Math.round(value)));
|
||||
}
|
||||
|
||||
function normalizeConfig(raw?: Partial<AppConfig>): AppConfig {
|
||||
const chat = raw?.chat;
|
||||
const automation = raw?.automation;
|
||||
const worklogAutomation = raw?.worklogAutomation;
|
||||
const planDefaults = raw?.planDefaults;
|
||||
const planCost = raw?.planCost;
|
||||
const gestureShortcuts = raw?.gestureShortcuts;
|
||||
|
||||
return {
|
||||
chat: {
|
||||
maxContextMessages: normalizeChatContextMessageLimit(
|
||||
chat?.maxContextMessages,
|
||||
DEFAULT_APP_CONFIG.chat.maxContextMessages,
|
||||
),
|
||||
maxContextChars: normalizeChatContextCharLimit(chat?.maxContextChars, DEFAULT_APP_CONFIG.chat.maxContextChars),
|
||||
},
|
||||
automation: {
|
||||
autoRefreshEnabled: automation?.autoRefreshEnabled ?? DEFAULT_APP_CONFIG.automation.autoRefreshEnabled,
|
||||
autoRefreshIntervalSeconds: clampIntervalSeconds(
|
||||
automation?.autoRefreshIntervalSeconds ?? DEFAULT_APP_CONFIG.automation.autoRefreshIntervalSeconds,
|
||||
DEFAULT_APP_CONFIG.automation.autoRefreshIntervalSeconds,
|
||||
),
|
||||
autoReceiveScheduleType: normalizeScheduleType(automation?.autoReceiveScheduleType),
|
||||
autoReceiveIntervalSeconds: clampIntervalSeconds(
|
||||
automation?.autoReceiveIntervalSeconds ?? DEFAULT_APP_CONFIG.automation.autoReceiveIntervalSeconds,
|
||||
DEFAULT_APP_CONFIG.automation.autoReceiveIntervalSeconds,
|
||||
),
|
||||
autoReceiveDailyTime: normalizeTimeValue(
|
||||
automation?.autoReceiveDailyTime ?? DEFAULT_APP_CONFIG.automation.autoReceiveDailyTime,
|
||||
DEFAULT_APP_CONFIG.automation.autoReceiveDailyTime,
|
||||
),
|
||||
autoReceiveWeeklyDay: normalizeWeeklyDay(automation?.autoReceiveWeeklyDay),
|
||||
autoReceiveWeeklyTime: normalizeTimeValue(
|
||||
automation?.autoReceiveWeeklyTime ?? DEFAULT_APP_CONFIG.automation.autoReceiveWeeklyTime,
|
||||
DEFAULT_APP_CONFIG.automation.autoReceiveWeeklyTime,
|
||||
),
|
||||
notifyOnAutomationStart: automation?.notifyOnAutomationStart ?? DEFAULT_APP_CONFIG.automation.notifyOnAutomationStart,
|
||||
notifyOnAutomationProgress:
|
||||
automation?.notifyOnAutomationProgress ?? DEFAULT_APP_CONFIG.automation.notifyOnAutomationProgress,
|
||||
notifyOnAutomationCompletion:
|
||||
automation?.notifyOnAutomationCompletion ?? DEFAULT_APP_CONFIG.automation.notifyOnAutomationCompletion,
|
||||
notifyOnAutomationRelease:
|
||||
automation?.notifyOnAutomationRelease ?? DEFAULT_APP_CONFIG.automation.notifyOnAutomationRelease,
|
||||
notifyOnAutomationMain: automation?.notifyOnAutomationMain ?? DEFAULT_APP_CONFIG.automation.notifyOnAutomationMain,
|
||||
notifyOnAutomationFailure:
|
||||
automation?.notifyOnAutomationFailure ?? DEFAULT_APP_CONFIG.automation.notifyOnAutomationFailure,
|
||||
notifyOnAutomationRestart:
|
||||
automation?.notifyOnAutomationRestart ?? DEFAULT_APP_CONFIG.automation.notifyOnAutomationRestart,
|
||||
notifyOnAutomationIssueResolved:
|
||||
automation?.notifyOnAutomationIssueResolved ?? DEFAULT_APP_CONFIG.automation.notifyOnAutomationIssueResolved,
|
||||
},
|
||||
worklogAutomation: {
|
||||
autoCreateDailyWorklog:
|
||||
worklogAutomation?.autoCreateDailyWorklog ?? DEFAULT_APP_CONFIG.worklogAutomation.autoCreateDailyWorklog,
|
||||
dailyCreateTime: normalizeTimeValue(
|
||||
worklogAutomation?.dailyCreateTime ?? DEFAULT_APP_CONFIG.worklogAutomation.dailyCreateTime,
|
||||
DEFAULT_APP_CONFIG.worklogAutomation.dailyCreateTime,
|
||||
),
|
||||
repeatRequestEnabled: false,
|
||||
repeatIntervalMinutes: DEFAULT_APP_CONFIG.worklogAutomation.repeatIntervalMinutes,
|
||||
includeScreenshots:
|
||||
worklogAutomation?.includeScreenshots ?? DEFAULT_APP_CONFIG.worklogAutomation.includeScreenshots,
|
||||
includeChangedFiles:
|
||||
worklogAutomation?.includeChangedFiles ?? DEFAULT_APP_CONFIG.worklogAutomation.includeChangedFiles,
|
||||
includeCommandLogs:
|
||||
worklogAutomation?.includeCommandLogs ?? DEFAULT_APP_CONFIG.worklogAutomation.includeCommandLogs,
|
||||
template: normalizeWorklogTemplate(worklogAutomation?.template),
|
||||
},
|
||||
planDefaults: {
|
||||
jangsingProcessingRequired:
|
||||
planDefaults?.jangsingProcessingRequired ?? DEFAULT_APP_CONFIG.planDefaults.jangsingProcessingRequired,
|
||||
autoDeployToMain: planDefaults?.autoDeployToMain ?? DEFAULT_APP_CONFIG.planDefaults.autoDeployToMain,
|
||||
openEditorAfterCreate: planDefaults?.openEditorAfterCreate ?? DEFAULT_APP_CONFIG.planDefaults.openEditorAfterCreate,
|
||||
},
|
||||
planCost: {
|
||||
baseCostPerMillionTokens: normalizePlanCostValue(
|
||||
planCost?.baseCostPerMillionTokens,
|
||||
DEFAULT_APP_CONFIG.planCost.baseCostPerMillionTokens,
|
||||
),
|
||||
retryCostMultiplierPercent: normalizePlanCostPercentValue(
|
||||
planCost?.retryCostMultiplierPercent,
|
||||
DEFAULT_APP_CONFIG.planCost.retryCostMultiplierPercent,
|
||||
),
|
||||
hourlyCostMultiplierPercent: normalizePlanCostPercentValue(
|
||||
planCost?.hourlyCostMultiplierPercent,
|
||||
DEFAULT_APP_CONFIG.planCost.hourlyCostMultiplierPercent,
|
||||
),
|
||||
timeCostUnit: normalizePlanCostTimeUnit(planCost?.timeCostUnit),
|
||||
attentionCostThresholdMultiplier: normalizePlanCostMultiplierValue(
|
||||
planCost?.attentionCostThresholdMultiplier,
|
||||
DEFAULT_APP_CONFIG.planCost.attentionCostThresholdMultiplier,
|
||||
),
|
||||
warningCostThresholdMultiplier: normalizePlanCostMultiplierValue(
|
||||
planCost?.warningCostThresholdMultiplier,
|
||||
DEFAULT_APP_CONFIG.planCost.warningCostThresholdMultiplier,
|
||||
),
|
||||
highCostThresholdMultiplier: normalizePlanCostMultiplierValue(
|
||||
planCost?.highCostThresholdMultiplier,
|
||||
DEFAULT_APP_CONFIG.planCost.highCostThresholdMultiplier,
|
||||
),
|
||||
},
|
||||
gestureShortcuts: {
|
||||
openSearch: normalizeShortcutValue(
|
||||
gestureShortcuts?.openSearch,
|
||||
DEFAULT_APP_CONFIG.gestureShortcuts.openSearch,
|
||||
),
|
||||
openWindowSearch: normalizeShortcutValue(
|
||||
gestureShortcuts?.openWindowSearch,
|
||||
DEFAULT_APP_CONFIG.gestureShortcuts.openWindowSearch,
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function pickAutomationNotificationSettings(automation: AppConfig['automation']): AutomationNotificationSettings {
|
||||
return AUTOMATION_NOTIFICATION_KEYS.reduce((picked, key) => {
|
||||
picked[key] = automation[key];
|
||||
return picked;
|
||||
}, {} as AutomationNotificationSettings);
|
||||
}
|
||||
|
||||
function mergeAutomationNotificationSettings(
|
||||
config: AppConfig,
|
||||
automation?: Partial<AutomationNotificationSettings> | null,
|
||||
) {
|
||||
if (!automation) {
|
||||
return config;
|
||||
}
|
||||
|
||||
return normalizeConfig({
|
||||
...config,
|
||||
automation: {
|
||||
...config.automation,
|
||||
...automation,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function emitConfigChange() {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
window.dispatchEvent(new Event(APP_CONFIG_EVENT));
|
||||
}
|
||||
|
||||
function resolveAppConfigApiBaseUrl() {
|
||||
if (import.meta.env.VITE_WORK_SERVER_URL) {
|
||||
return import.meta.env.VITE_WORK_SERVER_URL;
|
||||
}
|
||||
|
||||
return '/api';
|
||||
}
|
||||
|
||||
function resolveAppConfigFallbackBaseUrl() {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hostname = window.location.hostname;
|
||||
const isLocalWorkServerHost =
|
||||
hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '0.0.0.0';
|
||||
|
||||
if (!isLocalWorkServerHost) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fallbackUrl = new URL(window.location.origin);
|
||||
fallbackUrl.port = '3100';
|
||||
fallbackUrl.pathname = '/api';
|
||||
fallbackUrl.search = '';
|
||||
fallbackUrl.hash = '';
|
||||
|
||||
return fallbackUrl.toString().replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
const APP_CONFIG_API_BASE_URL = resolveAppConfigApiBaseUrl();
|
||||
const APP_CONFIG_FALLBACK_BASE_URL = resolveAppConfigFallbackBaseUrl();
|
||||
|
||||
class AppConfigApiError extends Error {
|
||||
status: number;
|
||||
|
||||
constructor(message: string, status: number) {
|
||||
super(message);
|
||||
this.name = 'AppConfigApiError';
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
async function requestAppConfigOnce<T>(baseUrl: string, path: string, init?: RequestInit): Promise<T> {
|
||||
const headers = appendClientIdHeader(init?.headers);
|
||||
const hasBody = init?.body !== undefined && init.body !== null;
|
||||
const method = init?.method?.toUpperCase() ?? 'GET';
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), APP_CONFIG_REQUEST_TIMEOUT_MS);
|
||||
|
||||
if (hasBody && !headers.has('Content-Type')) {
|
||||
headers.set('Content-Type', 'application/json');
|
||||
}
|
||||
|
||||
let response: Response;
|
||||
|
||||
try {
|
||||
response = await fetch(`${baseUrl}${path}`, {
|
||||
...init,
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
cache: init?.cache ?? (method === 'GET' ? 'no-store' : undefined),
|
||||
});
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||
throw new AppConfigApiError('서버 응답이 지연됩니다.', 408);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(text) as { message?: string };
|
||||
throw new AppConfigApiError(payload.message || '요청 처리에 실패했습니다.', response.status);
|
||||
} catch {
|
||||
throw new AppConfigApiError(text || '요청 처리에 실패했습니다.', response.status);
|
||||
}
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type') ?? '';
|
||||
if (!contentType.toLowerCase().includes('application/json')) {
|
||||
const text = await response.text();
|
||||
throw new AppConfigApiError(text ? '서버 응답이 JSON이 아닙니다.' : '서버 응답을 확인할 수 없습니다.', 502);
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
async function requestAppConfig<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
try {
|
||||
return await requestAppConfigOnce<T>(APP_CONFIG_API_BASE_URL, path, init);
|
||||
} catch (error) {
|
||||
const shouldRetryWithFallback =
|
||||
APP_CONFIG_FALLBACK_BASE_URL &&
|
||||
APP_CONFIG_FALLBACK_BASE_URL !== APP_CONFIG_API_BASE_URL &&
|
||||
(error instanceof AppConfigApiError
|
||||
? error.status === 404 || error.status === 408 || error.status === 502
|
||||
: error instanceof Error && /404|not found|Failed to fetch|Load failed|NetworkError/i.test(error.message));
|
||||
|
||||
if (!shouldRetryWithFallback) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return requestAppConfigOnce<T>(APP_CONFIG_FALLBACK_BASE_URL, path, init);
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchAppConfigFromServer() {
|
||||
try {
|
||||
const response = await requestAppConfig<{ ok: boolean; config: Partial<AppConfig> }>(APP_CONFIG_API_PATH);
|
||||
const config = normalizeConfig(response.config);
|
||||
const preference = await fetchAutomationNotificationPreferenceFromServer();
|
||||
return mergeAutomationNotificationSettings(config, preference);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchAutomationNotificationPreferenceFromServer() {
|
||||
try {
|
||||
const target = getAutomationNotificationPreferenceTarget();
|
||||
const query = target ? `?${new URLSearchParams(target).toString()}` : '';
|
||||
const response = await requestAppConfig<{
|
||||
ok: boolean;
|
||||
automation?: Partial<AutomationNotificationSettings> | null;
|
||||
}>(`${AUTOMATION_NOTIFICATION_PREFERENCE_API_PATH}${query}`);
|
||||
return response.automation ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveAppConfigToServer(config: AppConfig) {
|
||||
const response = await requestAppConfig<{ ok: boolean; config: Partial<AppConfig> }>(APP_CONFIG_API_PATH, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ config }),
|
||||
});
|
||||
|
||||
return normalizeConfig(response.config);
|
||||
}
|
||||
|
||||
export async function saveAutomationNotificationPreferenceToServer(config: AppConfig) {
|
||||
const target = getAutomationNotificationPreferenceTarget();
|
||||
const response = await requestAppConfig<{
|
||||
ok: boolean;
|
||||
automation: Partial<AutomationNotificationSettings>;
|
||||
}>(AUTOMATION_NOTIFICATION_PREFERENCE_API_PATH, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
...(target ?? {}),
|
||||
automation: pickAutomationNotificationSettings(config.automation),
|
||||
}),
|
||||
});
|
||||
|
||||
return mergeAutomationNotificationSettings(config, response.automation);
|
||||
}
|
||||
|
||||
export async function syncAppConfigFromServer() {
|
||||
const config = await fetchAppConfigFromServer();
|
||||
|
||||
if (!config) {
|
||||
return false;
|
||||
}
|
||||
|
||||
setStoredAppConfig(config);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function getStoredAppConfig(): AppConfig {
|
||||
if (typeof window === 'undefined') {
|
||||
return DEFAULT_APP_CONFIG;
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = window.localStorage.getItem(APP_CONFIG_STORAGE_KEY);
|
||||
|
||||
if (!raw) {
|
||||
cachedConfig = DEFAULT_APP_CONFIG;
|
||||
cachedRawConfig = null;
|
||||
return DEFAULT_APP_CONFIG;
|
||||
}
|
||||
|
||||
if (raw === cachedRawConfig && cachedConfig) {
|
||||
return cachedConfig;
|
||||
}
|
||||
|
||||
const normalized = normalizeConfig(JSON.parse(raw) as Partial<AppConfig>);
|
||||
cachedConfig = normalized;
|
||||
cachedRawConfig = raw;
|
||||
return normalized;
|
||||
} catch {
|
||||
cachedConfig = DEFAULT_APP_CONFIG;
|
||||
cachedRawConfig = null;
|
||||
return DEFAULT_APP_CONFIG;
|
||||
}
|
||||
}
|
||||
|
||||
export function setStoredAppConfig(config: AppConfig) {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalized = normalizeConfig(config);
|
||||
const raw = JSON.stringify(normalized);
|
||||
cachedConfig = normalized;
|
||||
cachedRawConfig = raw;
|
||||
window.localStorage.setItem(APP_CONFIG_STORAGE_KEY, raw);
|
||||
emitConfigChange();
|
||||
}
|
||||
|
||||
export function updateStoredAppConfig(updater: (current: AppConfig) => AppConfig) {
|
||||
const current = getStoredAppConfig();
|
||||
const next = updater(current);
|
||||
setStoredAppConfig(next);
|
||||
}
|
||||
|
||||
function subscribeToAppConfig(callback: () => void) {
|
||||
if (typeof window === 'undefined') {
|
||||
return () => undefined;
|
||||
}
|
||||
|
||||
const handleChange = () => {
|
||||
callback();
|
||||
};
|
||||
|
||||
window.addEventListener(APP_CONFIG_EVENT, handleChange);
|
||||
window.addEventListener('storage', handleChange);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener(APP_CONFIG_EVENT, handleChange);
|
||||
window.removeEventListener('storage', handleChange);
|
||||
};
|
||||
}
|
||||
|
||||
export function useAppConfig() {
|
||||
return useSyncExternalStore(subscribeToAppConfig, getStoredAppConfig, () => DEFAULT_APP_CONFIG);
|
||||
}
|
||||
|
||||
export function describeAutoReceiveSchedule(config: AppConfig) {
|
||||
const automation = config.automation;
|
||||
|
||||
if (automation.autoReceiveScheduleType === 'daily') {
|
||||
return `매일 ${automation.autoReceiveDailyTime}`;
|
||||
}
|
||||
|
||||
if (automation.autoReceiveScheduleType === 'weekly') {
|
||||
return `매주 ${WEEKLY_DAY_LABELS[automation.autoReceiveWeeklyDay]} ${automation.autoReceiveWeeklyTime}`;
|
||||
}
|
||||
|
||||
return `${automation.autoReceiveIntervalSeconds}초마다`;
|
||||
}
|
||||
|
||||
export function getWeeklyScheduleOptions() {
|
||||
return Object.entries(WEEKLY_DAY_LABELS).map(([value, label]) => ({
|
||||
value: value as WeeklyScheduleDay,
|
||||
label,
|
||||
}));
|
||||
}
|
||||
Reference in New Issue
Block a user