/
Starolat
/
DeepDive
Обзор
Документация
Войти
/
Starolat
/
DeepDive
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
js/services/UserSettingsService.js
1 281 строка
39 KB
Starolat Sergei
Удалена IndexedDB из сервисов настроек; синхронизация только через UserSettingsService/localStorage
30 июн 2026, 21:59
30 июн 2026, 21:59
9fd2369
Код
Авторство
О чём код?
// @ts-check /** * @fileoverview UserSettingsService — централизованное хранение пользовательских настроек (SYS-013) * @version 0.11.0 * @module UserSettingsService * * Единый источник истины для всех пользовательских настроек DeepDive Analytics. * Этап 11 реализации завершён: финальная документация и Component Registry. * * @fires user-settings-loaded — после успешной инициализации * @fires user-settings-changed — после локального изменения * @fires user-settings-saved — после успешной синхронизации с сервером * @fires user-settings-sync-pending — изменение поставлено в offline-очередь * @fires user-settings-error — при ошибке * @fires user-settings-conflict — при обнаружении конфликта * @fires user-settings-migrated — после миграции legacy-настроек */ /** * @typedef {import('../types.js').CustomSettingsDocument} CustomSettingsDocument * @typedef {import('../types.js').DeepDiveSettingsContainer} DeepDiveSettingsContainer * @typedef {import('../types.js').UserSettingsData} UserSettingsData * @typedef {import('../types.js').UserSettingsSyncStatus} UserSettingsSyncStatus * @typedef {import('../types.js').UserSettingsServiceConfig} UserSettingsServiceConfig * @typedef {import('../types.js').UserSettingsLoadSource} UserSettingsLoadSource * @typedef {import('../types.js').UserSettingsChangeDetail} UserSettingsChangeDetail * @typedef {import('../types.js').EncryptedAuthToken} EncryptedAuthToken */ import { config } from "../config.js"; import { fetchWithBearerAuth, authService, tokenService, } from "./auth/index.js"; const DOCUMENT_CACHE_KEY = "deepdive_user_settings_document"; const MIGRATION_FLAG_KEY = "deepdive_user_settings_migrated"; const DEFAULT_DOCUMENT_VERSION = "1.0.0"; const DEFAULT_THEME = "consta-light"; const DEFAULT_SYNC_DEBOUNCE_MS = 1000; const LEGACY_PRESETS_LS_KEY = "deepdive_presets"; /** @type {Record<string, string>} */ const LEGACY_LOCALSTORAGE_KEYS = { projects: "deepdive_projects", theme: "deepdive-theme", layoutState: "deepdive-layout-state-v1", sidebarLayouts: "deepdive-layouts", loadHistory: "deepdive_load_history", activePresetId: "deepdive_active_preset_id", authToken: "primavera_auth_token", }; /** * Проверить, что мы в браузере (а не в Node.js). * @returns {boolean} */ function _isBrowser() { return typeof window !== "undefined"; } /** * Проверить доступность localStorage. * @returns {boolean} */ function _hasLocalStorage() { try { if (typeof localStorage === "undefined") return false; const testKey = "__deepdive_uss_ls_test__"; localStorage.setItem(testKey, "1"); localStorage.removeItem(testKey); return true; } catch { return false; } } /** * Глубокое клонирование через JSON (safe для сериализуемых данных). * @template T * @param {T} value * @returns {T} */ function _deepClone(value) { if (value === undefined) return undefined; return JSON.parse(JSON.stringify(value)); } /** * Преобразовать ArrayBuffer в Base64 (использует atob/btoa, доступны в браузере и Node). * @param {ArrayBuffer} buffer * @returns {string} */ function _arrayBufferToBase64(buffer) { const bytes = new Uint8Array(buffer); let binary = ""; for (let i = 0; i < bytes.byteLength; i++) { binary += String.fromCharCode(bytes[i]); } return btoa(binary); } /** * Преобразовать Base64 в ArrayBuffer. * @param {string} base64 * @returns {ArrayBuffer} */ function _base64ToArrayBuffer(base64) { const binary = atob(base64); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) { bytes[i] = binary.charCodeAt(i); } return bytes.buffer; } /** * Проверить доступность Web Crypto API. * @returns {boolean} */ function _hasCryptoSubtle() { return typeof crypto !== "undefined" && !!crypto.subtle; } /** * Установить значение по точечному пути в объекте. * @param {Object} target * @param {string} path * @param {any} value */ function _setByPath(target, path, value) { const parts = path.split("."); let current = target; for (let i = 0; i < parts.length - 1; i++) { const key = parts[i]; if (current[key] === undefined || current[key] === null) { current[key] = {}; } current = current[key]; } current[parts[parts.length - 1]] = value; } /** * Прочитать значение по точечному пути из объекта. * @param {Object} target * @param {string} path * @param {any} [defaultValue] * @returns {any} */ function _getByPath(target, path, defaultValue) { if (!path) return target; const parts = path.split("."); let current = target; for (const key of parts) { if (current === undefined || current === null || !(key in current)) { return defaultValue; } current = current[key]; } return current === undefined ? defaultValue : current; } class UserSettingsService { constructor() { /** @type {CustomSettingsDocument|null} */ this._document = null; /** @type {DeepDiveSettingsContainer|null} */ this._deepDive = null; /** @type {Map<string, any>} */ this._pendingChanges = new Map(); /** @type {UserSettingsSyncStatus} */ this._syncStatus = { status: "idle", pendingCount: 0, lastSyncedAt: null, error: null, }; /** @type {UserSettingsServiceConfig} */ this._config = { apiUrl: config.USER_SETTINGS_SERVICE_URL || "https://spb99-askz-as1.gazprom-neft.local/apimsg/users/custom/settings", syncDebounceMs: config.SETTINGS_SYNC_DEBOUNCE_MS ?? DEFAULT_SYNC_DEBOUNCE_MS, debug: config.DEBUG_MODE ?? false, }; /** @type {boolean} */ this._initialized = false; /** @type {Promise<void>|null} */ this._initializing = null; /** @type {number|null} */ this._syncTimeout = null; /** @type {Function|null} */ this._onlineHandler = null; /** @type {Function|null} */ this._offlineHandler = null; /** @type {AbortController|null} */ this._abortController = null; /** @type {string|null} */ this._lastSyncedAt = null; /** @type {CryptoKey|null} */ this._encryptionKey = null; } // ==================== Singleton ==================== /** * @returns {UserSettingsService} */ static getInstance() { if (!UserSettingsService._instance) { UserSettingsService._instance = new UserSettingsService(); } return UserSettingsService._instance; } // ==================== Lifecycle ==================== /** * Инициализация сервиса: конфигурация, подписка на события сети, загрузка настроек. * * Этап 2: выполняет загрузку через {@link load}, которая при наличии сети обращается * к корпоративному API, а при отсутствии — использует локальный кэш. * * @param {UserSettingsServiceConfig} [options] * @returns {Promise<void>} */ async init(options = {}) { if (this._initialized) return; if (this._initializing) return this._initializing; this._initializing = this._doInit(options); try { await this._initializing; } finally { this._initializing = null; } } /** * Внутренняя инициализация. * * @param {UserSettingsServiceConfig} [options] * @returns {Promise<void>} */ async _doInit(options = {}) { this._config = { ...this._config, ...options, }; this._log("Initializing (Stage 2: API integration)"); this._log("API URL:", this._config.apiUrl); this._log( "Feature flag FEATURE_USER_SETTINGS_SERVICE:", config.FEATURE_USER_SETTINGS_SERVICE, ); this._abortController = typeof AbortController !== "undefined" ? new AbortController() : null; this._bindNetworkListeners(); // Этап 3: однократная миграция legacy-настроек до загрузки из API/кэша, // чтобы мигрированные данные участвовали в last-write-wins при sync. await this.migrateFromLocalStorage(); // Загружаем настройки: с сервера или из кэша this._log("Loading settings..."); await this.load(); this._ensureDefaults(); this._updateSyncStatus({ status: "idle" }); this._initialized = true; this._dispatchEvent("user-settings-loaded", { source: /** @type {UserSettingsLoadSource} */ ( this._lastLoadSource || "fallback" ), document: _deepClone(this._document), deepDive: _deepClone(this._deepDive), }); } /** * Очистка слушателей и таймеров. */ destroy() { this._unbindNetworkListeners(); if (this._syncTimeout) { clearTimeout(this._syncTimeout); this._syncTimeout = null; } if (this._abortController) { try { this._abortController.abort(); } catch { // ignore } this._abortController = null; } this._initialized = false; this._initializing = null; } // ==================== Reading ==================== /** * Вернуть полный snapshot настроек DeepDive из памяти. * @returns {UserSettingsData} */ getAll() { this._ensureInitOrThrow(); return _deepClone(this._deepDive.data); } /** * Прочитать значение по пути внутри deepDive.data. * @param {string} path — например, "theme" или "projects.0.name" * @param {any} [defaultValue] * @returns {any} */ get(path, defaultValue) { this._ensureInitOrThrow(); return _getByPath(this._deepDive.data, path, defaultValue); } /** * Проверить, что сервис полностью инициализирован и готов к использованию. * @returns {boolean} */ isReady() { return this._initialized; } // ==================== Writing ==================== /** * Установить значение по пути внутри deepDive.data. * Обновляет updatedAt, сохраняет в кэш, ставит в очередь синхронизации. * * @param {string} path — например, "theme" или "projects.0.name" * @param {any} value * @returns {Promise<void>} */ async set(path, value) { this._ensureInitOrThrow(); if (typeof path !== "string" || path.length === 0) { throw new Error( "[UserSettingsService] set() requires a non-empty string path", ); } const previousValue = this.get(path); const clonedValue = _deepClone(value); _setByPath(this._deepDive.data, path, clonedValue); this._deepDive.updatedAt = new Date().toISOString(); this._pendingChanges.set(path, clonedValue); await this._saveToCache(); if (!this.isOnline()) { this._updateSyncStatus({ status: "pending" }); this._dispatchEvent("user-settings-sync-pending", { pendingCount: this._pendingChanges.size, }); } /** @type {UserSettingsChangeDetail} */ const detail = { path, value: clonedValue, previousValue, synced: false, }; this._dispatchEvent("user-settings-changed", detail); this._scheduleSync(); } /** * Атомарно обновить несколько значений. * * @param {Record<string, any>} updates — объект {"путь": значение} * @returns {Promise<void>} */ async setMultiple(updates) { this._ensureInitOrThrow(); if (!updates || typeof updates !== "object" || Array.isArray(updates)) return; /** @type {Array<Omit<UserSettingsChangeDetail, 'synced'>>} */ const details = []; for (const [path, value] of Object.entries(updates)) { if (typeof path !== "string" || path.length === 0) continue; const previousValue = this.get(path); const clonedValue = _deepClone(value); _setByPath(this._deepDive.data, path, clonedValue); this._pendingChanges.set(path, clonedValue); details.push({ path, value: clonedValue, previousValue }); } if (details.length === 0) return; this._deepDive.updatedAt = new Date().toISOString(); await this._saveToCache(); if (!this.isOnline()) { this._updateSyncStatus({ status: "pending" }); this._dispatchEvent("user-settings-sync-pending", { pendingCount: this._pendingChanges.size, }); } this._dispatchEvent("user-settings-changed", { updates: details, synced: false, }); this._scheduleSync(); } // ==================== Auth Token Encryption ==================== /** * Сохранить авторизационный токен в зашифрованном виде. * * @param {string} plainToken * @returns {Promise<void>} */ async setToken(plainToken) { this._ensureInitOrThrow(); if (!plainToken) { return this.clearToken(); } try { const encrypted = await this._encryptToken(String(plainToken)); await this.set("authToken", encrypted); } catch (e) { this._log("Failed to set encrypted token:", e); this._dispatchError("token", e); throw e; } } /** * Получить расшифрованный авторизационный токен. * * @returns {Promise<string|null>} */ async getToken() { this._ensureInitOrThrow(); const authToken = this.get("authToken"); if (!authToken) return null; // Legacy: вдруг где-то остался plain string (не должен после миграции этапа 4) if (typeof authToken === "string") { this._log("Warning: authToken is stored as plain string"); return authToken; } try { return await this._decryptToken( /** @type {EncryptedAuthToken} */ (authToken), ); } catch (e) { this._log("Failed to decrypt token:", e); this._dispatchError("token", e); return null; } } /** * Удалить сохранённый токен. * * @returns {Promise<void>} */ async clearToken() { this._ensureInitOrThrow(); if (this.get("authToken") === null) return; await this.set("authToken", null); } /** * Получить или создать ключ шифрования AES-GCM. * Приоритет: * 1. Кэшированный в памяти ключ. * 2. Функция `config.encryptionKeyFn` (для тестов/кастомных сценариев). * 3. Base64-ключ из `config.SETTINGS_ENCRYPTION_KEY`. * 4. Временный ключ в памяти (generateKey), не восстанавливается после перезагрузки. * * @returns {Promise<CryptoKey>} * @private */ async _getEncryptionKey() { if (this._encryptionKey) return this._encryptionKey; if (!_hasCryptoSubtle()) { throw new Error("crypto.subtle is not available"); } if (this._config.encryptionKeyFn) { this._encryptionKey = await this._config.encryptionKeyFn(); return this._encryptionKey; } const base64Key = config.SETTINGS_ENCRYPTION_KEY; if (base64Key) { const raw = _base64ToArrayBuffer(base64Key); this._encryptionKey = await crypto.subtle.importKey( "raw", raw, { name: "AES-GCM" }, false, ["encrypt", "decrypt"], ); return this._encryptionKey; } // Fallback: временный ключ в памяти. Токен не восстановится после перезагрузки, // но не будет храниться plaintext. this._encryptionKey = await crypto.subtle.generateKey( { name: "AES-GCM", length: 256 }, true, ["encrypt", "decrypt"], ); return this._encryptionKey; } /** * Зашифровать строку токена через AES-GCM. * * @param {string} plainToken * @returns {Promise<EncryptedAuthToken>} * @private */ async _encryptToken(plainToken) { const key = await this._getEncryptionKey(); const encoder = new TextEncoder(); const iv = crypto.getRandomValues(new Uint8Array(12)); const encrypted = await crypto.subtle.encrypt( { name: "AES-GCM", iv }, key, encoder.encode(plainToken), ); // AES-GCM возвращает ciphertext + auth tag (16 bytes) в конце. const encryptedBytes = new Uint8Array(encrypted); const tagLength = 16; const ciphertextBytes = encryptedBytes.slice( 0, encryptedBytes.length - tagLength, ); const tagBytes = encryptedBytes.slice(encryptedBytes.length - tagLength); return { iv: _arrayBufferToBase64(iv), ciphertext: _arrayBufferToBase64(ciphertextBytes), tag: _arrayBufferToBase64(tagBytes), createdAt: Date.now(), }; } /** * Расшифровать EncryptedAuthToken. * * @param {EncryptedAuthToken} encryptedToken * @returns {Promise<string>} * @private */ async _decryptToken(encryptedToken) { const key = await this._getEncryptionKey(); const iv = _base64ToArrayBuffer(encryptedToken.iv); const ciphertext = _base64ToArrayBuffer(encryptedToken.ciphertext); const tag = _base64ToArrayBuffer(encryptedToken.tag); // Склеиваем ciphertext и tag обратно для AES-GCM const combined = new Uint8Array(ciphertext.byteLength + tag.byteLength); combined.set(new Uint8Array(ciphertext), 0); combined.set(new Uint8Array(tag), ciphertext.byteLength); const decrypted = await crypto.subtle.decrypt( { name: "AES-GCM", iv: new Uint8Array(iv) }, key, combined, ); return new TextDecoder().decode(decrypted); } // ==================== Server API ==================== /** * Загрузить общий документ настроек с сервера или из локального кэша. * Обновляет `_document`, `_deepDive` и локальный кэш (при загрузке с сервера). * * @returns {Promise<DeepDiveSettingsContainer>} */ async load() { let source = /** @type {UserSettingsLoadSource} */ ("fallback"); if (this.isOnline()) { try { const serverDoc = await this._fetchDocument(); if (serverDoc) { this._document = serverDoc; const serverDeepDive = serverDoc.deepDive; if (serverDeepDive) { // Last-write-wins: если в памяти уже есть более свежая версия // (например, только что мигрированная), сохраняем её. if ( this._deepDive && this._deepDive.updatedAt && serverDeepDive.updatedAt && new Date(this._deepDive.updatedAt) > new Date(serverDeepDive.updatedAt) ) { this._document.deepDive = _deepClone(this._deepDive); } else { this._deepDive = _deepClone(serverDeepDive); } } else if (!this._deepDive) { this._deepDive = this._createDefaultDeepDive(); } else { // Сервер не вернул deepDive, но в памяти есть мигрированные данные — // сохраняем их в документе перед кэшированием. this._document.deepDive = _deepClone(this._deepDive); } source = "server"; this._log("Loaded settings from server"); await this._saveToCache(); this._lastLoadSource = source; return _deepClone(this._deepDive); } } catch (e) { this._log("Failed to load from server, falling back to cache:", e); this._dispatchError("load", e); } } else { this._log("Offline, using local cache"); } const cached = await this._loadFromCache(); if (cached && cached.deepDive) { this._document = cached; this._deepDive = _deepClone(cached.deepDive); source = "cache"; this._log("Loaded settings from local cache"); } else { this._deepDive = this._createDefaultDeepDive(); this._document = { deepDive: _deepClone(this._deepDive) }; source = "fallback"; this._log("No settings found, using defaults"); } this._lastLoadSource = source; return _deepClone(this._deepDive); } /** * Однократная миграция legacy-настроек из разрозненных localStorage-ключей * и IndexedDB LayoutPresetService в единый контейнер deepDive. * * Этап 3: выполняется внутри init() до load(). * * @returns {Promise<boolean>} true, если были перенесены реальные данные. */ async migrateFromLocalStorage() { if (!_hasLocalStorage()) { this._log("localStorage unavailable, skipping migration"); return false; } if (localStorage.getItem(MIGRATION_FLAG_KEY) === "true") { this._log("Migration already performed, skipping"); return false; } this._log("Starting legacy settings migration"); const now = new Date().toISOString(); const migratedData = this._createDefaultDeepDive().data; /** @type {string[]} */ const migratedKeys = []; /** * Безопасно прочитать JSON legacy-ключ из localStorage. * @param {string} key * @returns {any|null} */ const readLegacyJson = (key) => { try { const raw = localStorage.getItem(key); if (raw === null) return null; return JSON.parse(raw); } catch (e) { this._log(`Warning: failed to parse legacy key ${key}:`, e); return null; } }; const projectLegacy = readLegacyJson(LEGACY_LOCALSTORAGE_KEYS.projects); if (projectLegacy !== null) { migratedData.projects = Array.isArray(projectLegacy) ? projectLegacy : []; migratedKeys.push("projects"); } const themeLegacy = localStorage.getItem(LEGACY_LOCALSTORAGE_KEYS.theme); if (themeLegacy !== null) { migratedData.theme = themeLegacy; migratedKeys.push("theme"); } const layoutStateLegacy = readLegacyJson( LEGACY_LOCALSTORAGE_KEYS.layoutState, ); if (layoutStateLegacy !== null) { migratedData.layoutState = layoutStateLegacy; migratedKeys.push("layoutState"); } const sidebarLayoutsLegacy = readLegacyJson( LEGACY_LOCALSTORAGE_KEYS.sidebarLayouts, ); if (sidebarLayoutsLegacy !== null) { migratedData.sidebarLayouts = Array.isArray(sidebarLayoutsLegacy) ? sidebarLayoutsLegacy : []; migratedKeys.push("sidebarLayouts"); } const loadHistoryLegacy = readLegacyJson( LEGACY_LOCALSTORAGE_KEYS.loadHistory, ); if (loadHistoryLegacy !== null) { migratedData.loadHistory = Array.isArray(loadHistoryLegacy) ? loadHistoryLegacy : []; migratedKeys.push("loadHistory"); } const activePresetIdLegacy = localStorage.getItem( LEGACY_LOCALSTORAGE_KEYS.activePresetId, ); if (activePresetIdLegacy !== null) { migratedData.activePresetId = activePresetIdLegacy; migratedKeys.push("activePresetId"); } // Auth token шифруется сразу при миграции (этап 4). try { const authTokenLegacy = localStorage.getItem( LEGACY_LOCALSTORAGE_KEYS.authToken, ); if (authTokenLegacy) { if (_hasCryptoSubtle()) { migratedData.authToken = await this._encryptToken(authTokenLegacy); migratedKeys.push("authToken"); } else { this._log( "Warning: crypto.subtle unavailable, skipping auth token migration", ); } } } catch (e) { this._log("Warning: failed to migrate/encrypt auth token:", e); } // Legacy-пресеты из localStorage (ранее LayoutPresetService). // IndexedDB больше не используется для хранения настроек. try { const raw = localStorage.getItem(LEGACY_PRESETS_LS_KEY); if (raw) { const parsed = JSON.parse(raw); if (Array.isArray(parsed) && parsed.length > 0) { migratedData.presets = parsed; migratedKeys.push("presets"); } } } catch (e) { this._log("Warning: failed to migrate legacy presets:", e); } // Даже если данных не было, отмечаем флаг, чтобы не повторять проверку. localStorage.setItem(MIGRATION_FLAG_KEY, "true"); if (migratedKeys.length === 0) { this._log("No legacy settings found, migration flag set"); return false; } this._deepDive = { version: DEFAULT_DOCUMENT_VERSION, updatedAt: now, data: migratedData, }; this._document = { deepDive: _deepClone(this._deepDive) }; await this._saveToCache(); this._dispatchEvent("user-settings-migrated", { migratedKeys, timestamp: now, }); this._log("Legacy settings migration completed:", migratedKeys); return true; } /** * Принудительная синхронизация с сервером. * Алиас для {@link sync}. * * @returns {Promise<void>} */ async save() { return this.sync(); } /** * Выполнить синхронизацию pending-изменений с сервером. * * @returns {Promise<void>} */ async sync() { if (!this._initialized) { this._log("sync() called before init, ignoring"); return; } if (!this.isOnline()) { this._updateSyncStatus({ status: "pending" }); this._dispatchEvent("user-settings-sync-pending", { pendingCount: this._pendingChanges.size, }); return; } if ( this._pendingChanges.size === 0 && this._syncStatus.status !== "error" ) { // Нечего синхронизировать; при ошибочном статусе позволяем retry return; } return this._doSync(); } /** * Выполнить fetch с авторизацией и однократным retry при 401/403. * При получении 401 пытается перелогиниться через authService.login() * и повторить запрос с новым токеном. * * @param {string} url * @param {RequestInit} options * @returns {Promise<Response>} * @private */ async _fetchWithAuthRetry(url, options = {}) { const method = (options.method || "GET").toUpperCase(); this._log(`[Settings API] _fetchWithAuthRetry() ${method} ${url}`); this._log("[Settings API] Token available:", !!tokenService.exist()); let response = await fetchWithBearerAuth(url, options); this._log( `_fetchWithAuthRetry() first response status: ${response.status}`, ); if (response.status === 401 || response.status === 403) { this._log( `Got ${response.status} from settings API, attempting re-login`, ); try { await authService.login(); this._log("_fetchWithAuthRetry() re-login succeeded, token available:", !!tokenService.exist()); response = await fetchWithBearerAuth(url, options); this._log( `_fetchWithAuthRetry() retry response status: ${response.status}`, ); } catch (loginError) { this._log("Re-login failed:", loginError); throw loginError; } } return response; } /** * Получить актуальный документ с сервера. * @returns {Promise<CustomSettingsDocument|null>} */ async _fetchDocument() { this._log("[Settings API] Step 1/3: Fetching document GET", this._config.apiUrl); this._log("[Settings API] Step 2/3: Using fetchWithBearerAuth (Bearer token expected)"); const response = await this._fetchWithAuthRetry(this._config.apiUrl, { method: "GET", headers: { Accept: "application/json" }, credentials: "include", signal: this._abortController?.signal, }); this._log("[Settings API] Step 3/3: Response status:", response.status); if (!response.ok) { if (response.status === 401 || response.status === 403) { this._dispatchError( "load", new Error(`Unauthorized access to settings API: ${response.status}`), ); } throw new Error(`Settings API returned ${response.status}`); } const doc = await response.json(); this._log("_fetchDocument() document keys:", Object.keys(doc)); if (!doc || typeof doc !== "object") { throw new Error("Settings API returned invalid JSON"); } return /** @type {CustomSettingsDocument} */ (doc); } /** * Отправить обновлённый документ на сервер. * @param {CustomSettingsDocument} doc * @returns {Promise<CustomSettingsDocument>} */ async _postDocument(doc) { this._log("[Settings API] Step 1/3: Saving document POST", this._config.apiUrl); this._log("[Settings API] Step 2/3: Using fetchWithBearerAuth (Bearer token expected)"); const response = await this._fetchWithAuthRetry(this._config.apiUrl, { method: "POST", headers: { "Content-Type": "application/json", Accept: "application/json", }, body: JSON.stringify(doc), credentials: "include", signal: this._abortController?.signal, }); this._log("[Settings API] Step 3/3: Response status:", response.status); if (!response.ok) { throw new Error(`Settings API POST returned ${response.status}`); } const saved = await response.json(); if (!saved || typeof saved !== "object") { return doc; } return /** @type {CustomSettingsDocument} */ (saved); } /** * Внутренняя синхронизация: GET → merge → POST. * @returns {Promise<void>} */ async _doSync() { this._updateSyncStatus({ status: "syncing", error: null }); // Snapshot изменений, которые синхронизируем прямо сейчас. // Изменения, сделанные во время выполнения sync, не должны теряться // при очистке очереди после успешного POST. const pendingSnapshot = new Set(this._pendingChanges.keys()); try { const serverDoc = await this._fetchDocument(); if (!serverDoc) { throw new Error("Server returned empty document"); } // Обнаружение конфликта: серверная версия новее локальной const serverDeepDive = serverDoc.deepDive; if ( serverDeepDive && serverDeepDive.updatedAt && this._deepDive.updatedAt && new Date(serverDeepDive.updatedAt) > new Date(this._deepDive.updatedAt) ) { this._dispatchEvent("user-settings-conflict", { serverUpdatedAt: serverDeepDive.updatedAt, clientUpdatedAt: this._deepDive.updatedAt, resolution: "client", }); } // Вставляем локальный deepDive в серверный документ, сохраняя чужие ключи const docToSave = _deepClone(serverDoc); docToSave.deepDive = _deepClone(this._deepDive); const savedDoc = await this._postDocument(docToSave); this._document = savedDoc; const now = new Date().toISOString(); this._lastSyncedAt = now; // Удаляем из очереди только те изменения, которые были на момент старта sync for (const key of pendingSnapshot) { this._pendingChanges.delete(key); } this._updateSyncStatus({ status: this._pendingChanges.size > 0 ? "pending" : "idle", lastSyncedAt: now, error: null, }); this._dispatchEvent("user-settings-saved", { updatedAt: this._deepDive.updatedAt, }); await this._saveToCache(); } catch (e) { this._updateSyncStatus({ status: "error", error: e instanceof Error ? e.message : String(e), }); this._dispatchError("sync", e); } } /** * Запланировать синхронизацию с debounce. * @private */ _scheduleSync() { if (this._syncTimeout) { clearTimeout(this._syncTimeout); } this._updateSyncStatus({ status: "pending" }); this._syncTimeout = setTimeout(() => { this.sync().catch((err) => { this._log("Scheduled sync failed:", err); }); }, this._config.syncDebounceMs); } // ==================== Local Cache ==================== /** * Загрузить документ из локального кэша. * @returns {Promise<CustomSettingsDocument|null>} */ async _loadFromCache() { if (!_hasLocalStorage()) return null; try { const raw = localStorage.getItem(DOCUMENT_CACHE_KEY); if (!raw) return null; const doc = JSON.parse(raw); if (!doc || typeof doc !== "object") return null; return doc; } catch (e) { this._log("Failed to load from cache:", e); this._dispatchError("load", e); return null; } } /** * Сохранить документ в локальный кэш. * @returns {Promise<void>} */ async _saveToCache() { if (!this._document || !this._deepDive) return; if (!_hasLocalStorage()) return; try { const docToCache = _deepClone(this._document || {}); // Убедимся, что кэш содержит актуальный deepDive из памяти docToCache.deepDive = _deepClone(this._deepDive); localStorage.setItem(DOCUMENT_CACHE_KEY, JSON.stringify(docToCache)); } catch (e) { this._log("Failed to save to cache:", e); this._dispatchError("save", e); } } // ==================== Defaults ==================== /** * Создать дефолтный контейнер настроек DeepDive. * @returns {DeepDiveSettingsContainer} */ _createDefaultDeepDive() { return { version: DEFAULT_DOCUMENT_VERSION, updatedAt: new Date().toISOString(), data: { projects: [], presets: [], activePresetId: null, layoutState: null, sidebarLayouts: [], theme: DEFAULT_THEME, loadHistory: [], authToken: null, }, }; } /** * Убедиться, что в памяти присутствуют все поля из дефолтов. */ _ensureDefaults() { if (!this._deepDive) { this._deepDive = this._createDefaultDeepDive(); } const defaults = this._createDefaultDeepDive().data; if (!this._deepDive.data) { this._deepDive.data = _deepClone(defaults); } for (const key of Object.keys(defaults)) { if (this._deepDive.data[key] === undefined) { this._deepDive.data[key] = _deepClone(defaults[key]); } } if (!this._deepDive.version) { this._deepDive.version = DEFAULT_DOCUMENT_VERSION; } if (!this._deepDive.updatedAt) { this._deepDive.updatedAt = new Date().toISOString(); } } /** * Сбросить настройки к локальным дефолтам. * @returns {Promise<void>} */ async resetToLocalDefaults() { this._deepDive = this._createDefaultDeepDive(); this._document = { deepDive: _deepClone(this._deepDive) }; this._pendingChanges.clear(); this._lastLoadSource = "fallback"; await this._saveToCache(); this._updateSyncStatus({ status: "idle", lastSyncedAt: null, error: null, }); } // ==================== Sync Status ==================== /** * @returns {UserSettingsSyncStatus} */ getSyncStatus() { return _deepClone(this._syncStatus); } /** * @param {Partial<UserSettingsSyncStatus>} update */ _updateSyncStatus(update) { this._syncStatus = { ...this._syncStatus, ...update, pendingCount: this._pendingChanges.size, }; } // ==================== Network ==================== /** * @returns {boolean} */ isOnline() { if (!_isBrowser()) return false; return navigator.onLine !== false; } _bindNetworkListeners() { if (!_isBrowser()) return; this._onlineHandler = () => { this._log("Network online"); this._updateSyncStatus({ status: "idle", error: null }); this.sync().catch((e) => { this._log("Sync on online event failed:", e); }); }; this._offlineHandler = () => { this._log("Network offline"); this._updateSyncStatus({ status: "pending", error: null }); }; window.addEventListener("online", this._onlineHandler); window.addEventListener("offline", this._offlineHandler); } _unbindNetworkListeners() { if (!_isBrowser() || !this._onlineHandler || !this._offlineHandler) return; window.removeEventListener("online", this._onlineHandler); window.removeEventListener("offline", this._offlineHandler); this._onlineHandler = null; this._offlineHandler = null; } // ==================== Private Helpers ==================== _ensureInitOrThrow() { if (!this._initialized) { throw new Error( "[UserSettingsService] Not initialized. Call init() first.", ); } } /** * @param {string} eventName * @param {any} detail */ _dispatchEvent(eventName, detail) { if (typeof document === "undefined") return; try { document.dispatchEvent( new CustomEvent(eventName, { detail: _deepClone(detail), bubbles: true, cancelable: true, }), ); } catch (e) { this._log("Failed to dispatch event:", e); } } /** * @param {'load'|'save'|'sync'|'migrate'|'token'} phase * @param {Error|string} error */ _dispatchError(phase, error) { this._dispatchEvent("user-settings-error", { phase, error: error instanceof Error ? error.message : String(error), }); } /** * @param {...any} args */ _log(...args) { if (this._config.debug) { console.log("[UserSettingsService]", ...args); } } // ==================== Singleton storage ==================== /** @type {UserSettingsService|null} */ static _instance = null; } export { UserSettingsService }; export default UserSettingsService;