669 lines
21 KiB
TypeScript
Executable File
669 lines
21 KiB
TypeScript
Executable File
import { useSyncExternalStore } from 'react';
|
|
import { appendClientIdHeader } from './clientIdentity';
|
|
import { getAutomationNotificationPreferenceTarget } from './notificationIdentity';
|
|
|
|
export 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;
|
|
codexLiveMaxExecutionSeconds: 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,
|
|
codexLiveMaxExecutionSeconds: 600,
|
|
},
|
|
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 normalizeCodexLiveMaxExecutionSeconds(value: number | undefined, fallback: number) {
|
|
if (value === undefined || !Number.isFinite(value)) {
|
|
return fallback;
|
|
}
|
|
|
|
return Math.min(7200, Math.max(60, 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),
|
|
codexLiveMaxExecutionSeconds: normalizeCodexLiveMaxExecutionSeconds(
|
|
chat?.codexLiveMaxExecutionSeconds,
|
|
DEFAULT_APP_CONFIG.chat.codexLiveMaxExecutionSeconds,
|
|
),
|
|
},
|
|
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,
|
|
}));
|
|
}
|