diff --git a/configuration/homeScreenAdvancedEditor.css b/configuration/homeScreenAdvancedEditor.css index 9280128..ac21481 100644 --- a/configuration/homeScreenAdvancedEditor.css +++ b/configuration/homeScreenAdvancedEditor.css @@ -81,6 +81,67 @@ text-overflow: ellipsis; } +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) + .formDialogHeader + .hsae-header-actions { + display: inline-flex; + align-items: center; + gap: 0.1em; + flex-shrink: 0; + margin-left: 0.15em; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) + .formDialogHeader + .hsae-header-action-btn { + flex-shrink: 0; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) + .formDialogHeader + .hsae-header-action-btn[hidden] { + display: none !important; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) + .formDialogHeader + .hsae-header-action-btn + .material-icons { + font-size: 1.35rem; +} + +.hsae-restore-diff-table { + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 8px; + font-size: 0.85rem; +} + +.hsae-restore-diff-row { + display: grid; + grid-template-columns: minmax(5.5rem, 0.9fr) 1.1fr 1.1fr; + gap: 0.5em; + padding: 0.45em 0.65em; + border-top: 1px solid rgba(255, 255, 255, 0.08); + align-items: start; +} + +.hsae-restore-diff-row:first-child { + border-top: none; +} + +.hsae-restore-diff-head { + font-weight: 600; + opacity: 0.85; + background: rgba(255, 255, 255, 0.04); +} + +.hsae-restore-diff-row > span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + :is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-group { margin-bottom: 1.25em; } diff --git a/configuration/homeScreenAdvancedEditor.js b/configuration/homeScreenAdvancedEditor.js index e6fea7c..b5b4304 100644 --- a/configuration/homeScreenAdvancedEditor.js +++ b/configuration/homeScreenAdvancedEditor.js @@ -3640,6 +3640,653 @@ } } + const HSAE_KEFIN_DEFAULT_GROUP_KEYS = [ + 'HOME_SECTION_GROUPS', + 'SEASONAL_SECTION_GROUPS', + 'DISCOVERY_SECTION_GROUPS' + ]; + const HSAE_SECTION_EXPORT_MODAL_ID = 'hsae-section-export'; + const HSAE_SECTION_IMPORT_MODAL_ID = 'hsae-section-import-paste'; + const HSAE_RESTORE_DEFAULTS_MODAL_ID = 'hsae-section-restore-defaults'; + const HSAE_SECTION_DIFF_KEYS = [ + 'name', 'caption', 'description', 'enabled', 'order', 'cardFormat', 'renderMode', 'ttl', + 'queries', 'items', 'externalListUrls', 'spotlightConfig', 'hideName', 'userConfigurable', + 'useRandomQuery', 'useMultiQueryPicker', 'multiQueryPickerLabel', 'useQueryNamesForSection', + 'sortBy', 'sortOrder', 'sortOrderDirection', 'itemLimit', 'minimumItems', 'flattenSeries', + 'limitBeforeSort', 'sectionCssClass', 'borderStyle', 'borderColor', 'cardTitleColor', + 'itemsLayout', 'useGaplessCards', 'cardTitleVisibility', 'useParentCard', 'cardTitlePosition', + 'cardTitleCapitalization', 'cardTitleFontFamily', 'cardTitleFontSize', 'startDate', 'endDate', + 'hidden', 'discoveryEnabled', 'type', 'category', 'livePreviewUpdates' + ]; + + function showHsaeToast(message, duration = '3') { + if (window.KefinTweaksToaster?.toast) { + window.KefinTweaksToaster.toast(message, duration); + } + } + + function deepCloneJson(value) { + return value == null ? value : JSON.parse(JSON.stringify(value)); + } + + /** collectData writes these even when canonical omits them (runtime defaults). */ + const HSAE_IMPLICIT_SECTION_DEFAULTS = { + renderMode: 'Normal', + useGaplessCards: false, + useParentCard: false, + discoveryEnabled: false, + livePreviewUpdates: false, + hideName: false, + useRandomQuery: false, + useMultiQueryPicker: false, + useQueryNamesForSection: false, + flattenSeries: false, + limitBeforeSort: false, + hideCardTitles: false, + category: 'none' + }; + + const HSAE_GROUP_KEY_TO_TYPE = { + HOME_SECTION_GROUPS: 'home', + SEASONAL_SECTION_GROUPS: 'seasonal', + DISCOVERY_SECTION_GROUPS: 'discovery' + }; + + function getDefaultTtlMs() { + const preset = getCachePresets().find((p) => p.key === 'DEFAULT'); + return preset?.ms ?? 3600000; + } + + /** Empty / missing editor values are equivalent for restore diffs ("" vs omitted). */ + function normalizeForDiff(value) { + if (value === undefined || value === null) return null; + if (typeof value === 'string' && value.trim() === '') return null; + if (Array.isArray(value) && value.length === 0) return null; + if (typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length === 0) { + return null; + } + return value; + } + + function omitEmptyDeep(value) { + if (value === undefined || value === null || value === '') return undefined; + if (Array.isArray(value)) { + return value.map(omitEmptyDeep).filter((v) => v !== undefined); + } + if (typeof value === 'object') { + const out = {}; + Object.keys(value).sort().forEach((key) => { + const nested = omitEmptyDeep(value[key]); + if (nested === undefined) return; + if (typeof nested === 'object' && !Array.isArray(nested) && Object.keys(nested).length === 0) { + return; + } + out[key] = nested; + }); + return out; + } + return value; + } + + function stableStringify(value) { + if (value === undefined) return 'null'; + if (value === null || typeof value !== 'object') { + try { + return JSON.stringify(value); + } catch (_) { + return String(value); + } + } + if (Array.isArray(value)) { + return `[${value.map(stableStringify).join(',')}]`; + } + const keys = Object.keys(value).sort(); + return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify(value[k])}`).join(',')}}`; + } + + function coerceQueryOptionValue(val) { + if (typeof val === 'string' && val.trim() !== '' && /^-?\d+(\.\d+)?$/.test(val.trim())) { + const n = Number(val); + if (Number.isFinite(n)) return n; + } + return val; + } + + function normalizeQueryForCompare(query) { + if (!query || typeof query !== 'object') return null; + const clone = deepCloneJson(query) || {}; + Object.keys(clone).forEach((key) => { + if (key.startsWith('_')) delete clone[key]; + }); + if (clone.queryOptions && typeof clone.queryOptions === 'object') { + Object.keys(clone.queryOptions).forEach((key) => { + if (key.startsWith('_')) { + delete clone.queryOptions[key]; + return; + } + clone.queryOptions[key] = coerceQueryOptionValue(clone.queryOptions[key]); + }); + } + return omitEmptyDeep(clone) || {}; + } + + function normalizeQueriesForCompare(queries) { + if (!Array.isArray(queries)) return null; + return queries.map(normalizeQueryForCompare); + } + + function normalizeItemsForCompare(items) { + if (!Array.isArray(items)) return null; + return items.map((item) => { + if (!item || typeof item !== 'object') return item; + const clone = deepCloneJson(item) || {}; + const name = clone.Name || clone.name; + if (name != null) { + clone.Name = name; + delete clone.name; + } + ['posterUrl', 'thumbUrl', 'backdropUrl', 'squareUrl', 'bannerUrl', 'logoUrl', 'imageUrl', 'cardFooter', 'cardUrl'] + .forEach((key) => { + if (typeof clone[key] === 'string' && !clone[key].trim()) delete clone[key]; + }); + return omitEmptyDeep(clone) || {}; + }); + } + + function normalizeDiffFieldValue(key, value, section) { + if (key === 'queries') { + const normalized = normalizeQueriesForCompare(Array.isArray(value) ? value : null); + if (!normalized || normalized.length === 0) return null; + return normalized; + } + if (key === 'items') { + const normalized = normalizeItemsForCompare(Array.isArray(value) ? value : null); + if (!normalized || normalized.length === 0) return null; + return normalized; + } + + let normalized = normalizeForDiff(value); + if (Object.prototype.hasOwnProperty.call(HSAE_IMPLICIT_SECTION_DEFAULTS, key)) { + const implicit = HSAE_IMPLICIT_SECTION_DEFAULTS[key]; + if (normalized === null || valuesRawEqual(normalized, implicit)) { + return null; + } + } + if (key === 'ttl') { + const defaultTtl = getDefaultTtlMs(); + if (normalized === null || valuesRawEqual(Number(normalized), defaultTtl)) { + return null; + } + } + if (key === 'type') { + const derived = deriveSectionSurfaceType(section); + if (normalized === null || valuesRawEqual(normalized, derived)) { + return derived; + } + } + if (key === 'hideName' && (normalized === false || normalized === null)) return null; + return normalized; + } + + function valuesRawEqual(a, b) { + try { + return stableStringify(a) === stableStringify(b); + } catch (_) { + return a === b; + } + } + + function deriveSectionSurfaceType(section) { + if (!section) return 'home'; + const existing = String(section.type || '').toLowerCase(); + if (existing === 'home' || existing === 'seasonal' || existing === 'discovery') return existing; + if (section.discoveryEnabled === true || section.discoveryType) return 'discovery'; + if (section.startDate?.trim?.() && section.endDate?.trim?.()) return 'seasonal'; + if (section.startDate && section.endDate) return 'seasonal'; + return 'home'; + } + + function valuesDeepEqual(a, b, key, sectionA, sectionB) { + try { + const left = key != null + ? normalizeDiffFieldValue(key, a, sectionA) + : normalizeForDiff(a); + const right = key != null + ? normalizeDiffFieldValue(key, b, sectionB) + : normalizeForDiff(b); + return stableStringify(left) === stableStringify(right); + } catch (_) { + return normalizeForDiff(a) === normalizeForDiff(b); + } + } + + function getCanonicalDefaultSection(sectionId) { + if (!sectionId) return null; + const defaults = window.KefinHomeConfig2; + if (!defaults) return null; + for (const groupKey of HSAE_KEFIN_DEFAULT_GROUP_KEYS) { + const groups = defaults[groupKey]; + if (!Array.isArray(groups)) continue; + for (const group of groups) { + const sections = group?.sections; + if (!Array.isArray(sections)) continue; + const found = sections.find((s) => s?.id === sectionId); + if (found) { + const clone = deepCloneJson(found); + const surfaceType = HSAE_GROUP_KEY_TO_TYPE[groupKey]; + if (clone && surfaceType && !clone.type) clone.type = surfaceType; + return clone; + } + } + } + return null; + } + + function isKefinDefaultSection(section, context) { + if (context?.isCustomSection === true) return false; + const id = section?.id; + if (!id) return false; + return getCanonicalDefaultSection(id) != null; + } + + function parseSectionImportPayload(text) { + if (text == null || String(text).trim() === '') return null; + let parsed; + try { + parsed = JSON.parse(String(text)); + } catch (_) { + return null; + } + if (Array.isArray(parsed)) { + if (parsed.length !== 1) return null; + parsed = parsed[0]; + } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null; + const hasShape = ['name', 'queries', 'items', 'renderMode', 'id', 'cardFormat'] + .some((key) => Object.prototype.hasOwnProperty.call(parsed, key)); + if (!hasShape) return null; + return parsed; + } + + function exportCurrentSectionJson(modalInstance) { + const dialog = modalInstance?.dialogContainer || modalInstance?.dialogContent; + const state = modalInstance?._hsaeState; + if (!dialog || !state) return null; + const original = state.context?.originalSection || state.section || {}; + const section = collectData(dialog, original); + if (!section) return null; + if (state.section?.id) section.id = state.section.id; + return JSON.stringify(section, null, 2); + } + + function applyImportedSection(modalInstance, importedSection) { + const state = modalInstance?._hsaeState; + if (!state || !importedSection) return; + const current = state.section || {}; + const context = state.context || {}; + const merged = deepCloneJson(importedSection) || {}; + merged.id = current.id || merged.id; + if (current.deleted === true) merged.deleted = true; + const newState = buildEditorStateFromSection(merged, context); + modalInstance._hsaeState = newState; + const stateHost = modalInstance.dialogContainer || modalInstance.dialogContent; + if (stateHost) stateHost._hsaeState = newState; + activeEditorState = newState; + refreshEditorBody(modalInstance, { skipCollect: true }); + updateSectionNameBadge(modalInstance); + } + + function abbreviateDiffValue(value, { markChanged = false } = {}) { + if (value === undefined) return '(none)'; + if (value === null) return '(none)'; + if (Array.isArray(value)) { + if (value.length === 0) return '[]'; + const base = `array (${value.length})`; + return markChanged ? `${base} · changed` : base; + } + if (typeof value === 'object') { + const keys = Object.keys(value); + return keys.length ? `object (${keys.length} keys)` : '{}'; + } + if (typeof value === 'boolean' || typeof value === 'number') return String(value); + const s = String(value); + if (!s) return '""'; + return s.length > 48 ? `${s.slice(0, 45)}…` : s; + } + + function diffSectionAgainstDefault(current, canonical) { + const diffs = []; + if (!current || !canonical) return diffs; + for (const key of HSAE_SECTION_DIFF_KEYS) { + const hasCurrent = Object.prototype.hasOwnProperty.call(current, key); + const hasCanonical = Object.prototype.hasOwnProperty.call(canonical, key); + if (!hasCurrent && !hasCanonical) continue; + const curVal = hasCurrent ? current[key] : undefined; + const defVal = hasCanonical ? canonical[key] : undefined; + if (valuesDeepEqual(curVal, defVal, key, current, canonical)) continue; + + const normCur = normalizeDiffFieldValue(key, curVal, current); + const normDef = normalizeDiffFieldValue(key, defVal, canonical); + const bothArraysSameLen = Array.isArray(normCur) && Array.isArray(normDef) + && normCur.length === normDef.length + && normCur.length > 0; + diffs.push({ + key, + current: abbreviateDiffValue(normCur ?? curVal, { markChanged: bothArraysSameLen }), + default: abbreviateDiffValue(normDef ?? defVal, { markChanged: bothArraysSameLen }) + }); + } + return diffs; + } + + function isHttpsContextForClipboard() { + try { + if (typeof ApiClient !== 'undefined' && ApiClient._serverAddress?.startsWith?.('https://')) return true; + } catch (_) { /* ignore */ } + return typeof location !== 'undefined' && location.protocol === 'https:'; + } + + function canUseClipboardWrite() { + return !!(isHttpsContextForClipboard() + && navigator.clipboard + && typeof navigator.clipboard.writeText === 'function'); + } + + function canUseClipboardRead() { + return !!(isHttpsContextForClipboard() + && navigator.clipboard + && typeof navigator.clipboard.readText === 'function'); + } + + function showSectionExportModal(json) { + if (!window.ModalSystem) { + showHsaeToast('Unable to open export modal'); + return; + } + if (window.ModalSystem.isOpen?.(HSAE_SECTION_EXPORT_MODAL_ID)) { + window.ModalSystem.close(HSAE_SECTION_EXPORT_MODAL_ID); + } + const canCopy = canUseClipboardWrite(); + window.ModalSystem.create({ + id: HSAE_SECTION_EXPORT_MODAL_ID, + title: 'Export Section', + content: ` +
Name and poster required. Link and other images optional.
+Name required; poster or link recommended. Other images optional.