diff --git a/src/prototypes/morelike-playground/EditAttributionLine.vue b/src/prototypes/morelike-playground/EditAttributionLine.vue new file mode 100644 index 0000000..96fed26 --- /dev/null +++ b/src/prototypes/morelike-playground/EditAttributionLine.vue @@ -0,0 +1,48 @@ + + + + diff --git a/src/prototypes/morelike-playground/fetchMorelike.ts b/src/prototypes/morelike-playground/fetchMorelike.ts new file mode 100644 index 0000000..1118936 --- /dev/null +++ b/src/prototypes/morelike-playground/fetchMorelike.ts @@ -0,0 +1,458 @@ +import { wikimediaApiFetchHeaders } from '@/config' + +import { + resolveMltParams, + type MorelikeMltCustomSettings, + type MorelikeMltPreset, + type MorelikeSortOrder, +} from './morelikeMlt' + +const WIKI_HOST = 'en.wikipedia.org' +const API_URL = `https://${WIKI_HOST}/w/api.php` + +export interface MorelikeSearchHit { + title: string + description: string + timestamp: string + pageUrl: string + revisionAuthor?: string + revisionComment?: string + revisionContent?: string + thumbnail?: { + url: string + width: number + height: number + } +} + +interface GeneratorRevision { + timestamp?: string + user?: string + comment?: string + slots?: { + main?: { + content?: string + } + } +} + +interface GeneratorPage { + title: string + index?: number + description?: string + thumbnail?: { + source: string + width: number + height: number + } + revisions?: GeneratorRevision[] +} + +interface GeneratorSearchResponse { + error?: { + code?: string + info?: string + } + query?: { + pages?: GeneratorPage[] + } +} + +export class MorelikeFetchError extends Error { + constructor( + message: string, + public readonly code: 'aborted' | 'http' | 'empty', + ) { + super(message) + this.name = 'MorelikeFetchError' + } +} + +/** Split seed input on newlines and commas; trim, dedupe, drop empties. */ +export function parseSeedTitles(raw: string): string[] { + const seen = new Set() + const titles: string[] = [] + + for (const part of raw.split(/[\n,]+/)) { + const title = part.trim() + if (!title.length || seen.has(title)) continue + seen.add(title) + titles.push(title) + } + + return titles +} + +export function buildSrsearch(seedText: string): string | null { + const seeds = parseSeedTitles(seedText) + if (!seeds.length) return null + + return `morelike:${seeds.join('|')}` +} + +export function buildSrsearchForSeed(title: string): string { + return `morelike:${title}` +} + +/** Wire preview: combined query when off, one line per seed when interleaving. */ +export function buildSrsearchPreview(seedText: string, interleave: boolean): string | null { + const seeds = parseSeedTitles(seedText) + if (!seeds.length) return null + + if (!interleave) { + return buildSrsearch(seedText) + } + + return seeds.map(buildSrsearchForSeed).join('\n') +} + +/** Split total result limit across seeds (e.g. 20 / 3 → [7, 7, 6]). */ +export function perSeedLimits(totalLimit: number, seedCount: number): number[] { + if (seedCount <= 0) return [] + + const base = Math.floor(totalLimit / seedCount) + const extra = totalLimit % seedCount + + return Array.from({ length: seedCount }, (_, i) => base + (i < extra ? 1 : 0)) +} + +/** Round-robin merge with dedupe; caps at totalLimit. */ +export function interleaveMorelikeHits( + lists: MorelikeSearchHit[][], + totalLimit: number, +): MorelikeSearchHit[] { + if (!lists.length) return [] + + const seen = new Set() + const result: MorelikeSearchHit[] = [] + const maxLen = Math.max(...lists.map((list) => list.length)) + + for (let i = 0; i < maxLen && result.length < totalLimit; i++) { + for (const list of lists) { + if (result.length >= totalLimit) break + + const hit = list[i] + if (!hit || seen.has(hit.title)) continue + + seen.add(hit.title) + result.push(hit) + } + } + + return result +} + +function articleUrl(title: string): string { + return `https://${WIKI_HOST}/wiki/${encodeURIComponent(title.replace(/ /g, '_'))}` +} + +function buildMorelikeSearchParams( + gsrsearch: string, + limit: number, + mltParams: Record, + classicNoboostlinks: boolean, +): URLSearchParams { + const params = new URLSearchParams({ + action: 'query', + generator: 'search', + gsrsearch, + gsrnamespace: '0', + gsrlimit: String(limit), + prop: 'pageimages|description|revisions', + piprop: 'thumbnail', + pithumbsize: '160', + rvprop: 'timestamp|user|comment|content', + rvslots: 'main', + format: 'json', + formatversion: '2', + origin: '*', + }) + + if (classicNoboostlinks) { + params.set('gsrqiprofile', 'classic_noboostlinks') + } + + for (const [key, value] of Object.entries(mltParams)) { + params.set(key, value) + } + + return params +} + +function buildMorelikeApiRequestUrlFromGsrsearch( + gsrsearch: string, + limit: number, + mltPreset: MorelikeMltPreset, + mltCustom: MorelikeMltCustomSettings, + classicNoboostlinks: boolean, +): string { + const params = buildMorelikeSearchParams( + gsrsearch, + limit, + resolveMltParams(mltPreset, mltCustom), + classicNoboostlinks, + ) + return `${API_URL}?${params.toString()}` +} + +/** Exact URL(s) sent to the Action API (shared by fetch + UI). */ +export function buildMorelikeApiRequestUrls( + seedText: string, + totalLimit: number, + mltPreset: MorelikeMltPreset, + mltCustom: MorelikeMltCustomSettings, + classicNoboostlinks = true, + interleave = false, +): string[] { + const seeds = parseSeedTitles(seedText) + if (!seeds.length) return [] + + if (!interleave) { + const gsrsearch = buildSrsearch(seedText) + if (!gsrsearch) return [] + + return [ + buildMorelikeApiRequestUrlFromGsrsearch( + gsrsearch, + totalLimit, + mltPreset, + mltCustom, + classicNoboostlinks, + ), + ] + } + + const limits = perSeedLimits(totalLimit, seeds.length) + const urls: string[] = [] + + for (let i = 0; i < seeds.length; i++) { + if (limits[i] <= 0) continue + + urls.push( + buildMorelikeApiRequestUrlFromGsrsearch( + buildSrsearchForSeed(seeds[i]), + limits[i], + mltPreset, + mltCustom, + classicNoboostlinks, + ), + ) + } + + return urls +} + +/** Exact URL sent to the Action API (shared by fetch + UI). */ +export function buildMorelikeApiRequestUrl( + seedText: string, + limit: number, + mltPreset: MorelikeMltPreset, + mltCustom: MorelikeMltCustomSettings, + classicNoboostlinks = true, + interleave = false, +): string | null { + const urls = buildMorelikeApiRequestUrls( + seedText, + limit, + mltPreset, + mltCustom, + classicNoboostlinks, + interleave, + ) + + if (!urls.length) return null + + return urls.join('\n') +} + +async function fetchMorelikeHits( + requestUrl: string, + signal?: AbortSignal, +): Promise { + const response = await fetch(requestUrl, { + signal, + headers: wikimediaApiFetchHeaders('morelike-search'), + }) + + if (!response.ok) { + throw new MorelikeFetchError(`Search failed (HTTP ${response.status})`, 'http') + } + + const data = (await response.json()) as GeneratorSearchResponse + + if (data.error) { + throw new MorelikeFetchError(data.error.info ?? data.error.code ?? 'Search failed', 'http') + } + + const pages = data.query?.pages ?? [] + + return [...pages].sort((a, b) => (a.index ?? 0) - (b.index ?? 0)) +} + +function mapRevisionFields(revision: GeneratorRevision | undefined): { + timestamp: string + author?: string + comment?: string + content?: string +} { + return { + timestamp: revision?.timestamp ?? '', + author: revision?.user, + comment: revision?.comment, + content: revision?.slots?.main?.content, + } +} + +function mapPageToHit(page: GeneratorPage): MorelikeSearchHit { + const latest = mapRevisionFields(page.revisions?.[0]) + + return { + title: page.title, + description: page.description?.trim() ?? '', + timestamp: latest.timestamp, + pageUrl: articleUrl(page.title), + revisionAuthor: latest.author, + revisionComment: latest.comment, + revisionContent: latest.content, + thumbnail: page.thumbnail + ? { + url: page.thumbnail.source, + width: page.thumbnail.width, + height: page.thumbnail.height, + } + : undefined, + } +} + +function sortByNewestEditFirst(hits: MorelikeSearchHit[]): MorelikeSearchHit[] { + return [...hits].sort((a, b) => { + const aTime = a.timestamp ? Date.parse(a.timestamp) : Number.NaN + const bTime = b.timestamp ? Date.parse(b.timestamp) : Number.NaN + + if (Number.isNaN(aTime) && Number.isNaN(bTime)) return 0 + if (Number.isNaN(aTime)) return 1 + if (Number.isNaN(bTime)) return -1 + + return bTime - aTime + }) +} + +/** Client-side sort — API results always stay in relevance order. */ +export function sortMorelikeHits( + hits: MorelikeSearchHit[], + sortOrder: MorelikeSortOrder, +): MorelikeSearchHit[] { + if (sortOrder === 'lastEdit') { + return sortByNewestEditFirst(hits) + } + + return hits +} + +async function fetchMorelikeResultsCombined( + seedText: string, + options: { + limit: number + mltPreset: MorelikeMltPreset + mltCustom: MorelikeMltCustomSettings + classicNoboostlinks: boolean + signal?: AbortSignal + }, +): Promise { + const requestUrl = buildMorelikeApiRequestUrl( + seedText, + options.limit, + options.mltPreset, + options.mltCustom, + options.classicNoboostlinks, + false, + ) + + if (!requestUrl) { + throw new MorelikeFetchError('Add at least one seed page title', 'empty') + } + + const seedTitles = new Set(parseSeedTitles(seedText)) + const pages = await fetchMorelikeHits(requestUrl, options.signal) + + return pages + .filter((page) => !seedTitles.has(page.title)) + .map(mapPageToHit) +} + +async function fetchMorelikeResultsInterleaved( + seedText: string, + options: { + limit: number + mltPreset: MorelikeMltPreset + mltCustom: MorelikeMltCustomSettings + classicNoboostlinks: boolean + signal?: AbortSignal + }, +): Promise { + const seeds = parseSeedTitles(seedText) + if (!seeds.length) { + throw new MorelikeFetchError('Add at least one seed page title', 'empty') + } + + const seedTitles = new Set(seeds) + const limits = perSeedLimits(options.limit, seeds.length) + const lists: MorelikeSearchHit[][] = [] + + for (let i = 0; i < seeds.length; i++) { + if (options.signal?.aborted) { + throw new MorelikeFetchError('Request aborted', 'aborted') + } + + const limit = limits[i] + if (limit <= 0) continue + + const requestUrl = buildMorelikeApiRequestUrlFromGsrsearch( + buildSrsearchForSeed(seeds[i]), + limit, + options.mltPreset, + options.mltCustom, + options.classicNoboostlinks, + ) + + const pages = await fetchMorelikeHits(requestUrl, options.signal) + + lists.push( + pages + .filter((page) => !seedTitles.has(page.title)) + .map(mapPageToHit), + ) + } + + return interleaveMorelikeHits(lists, options.limit) +} + +export async function fetchMorelikeResults( + seedText: string, + options: { + limit: number + mltPreset: MorelikeMltPreset + mltCustom: MorelikeMltCustomSettings + classicNoboostlinks?: boolean + interleave?: boolean + signal?: AbortSignal + }, +): Promise { + if (options.signal?.aborted) { + throw new MorelikeFetchError('Request aborted', 'aborted') + } + + const fetchOptions = { + limit: options.limit, + mltPreset: options.mltPreset, + mltCustom: options.mltCustom, + classicNoboostlinks: options.classicNoboostlinks ?? true, + signal: options.signal, + } + + if (options.interleave) { + return fetchMorelikeResultsInterleaved(seedText, fetchOptions) + } + + return fetchMorelikeResultsCombined(seedText, fetchOptions) +} diff --git a/src/prototypes/morelike-playground/formatEditComment.ts b/src/prototypes/morelike-playground/formatEditComment.ts new file mode 100644 index 0000000..5b5827f --- /dev/null +++ b/src/prototypes/morelike-playground/formatEditComment.ts @@ -0,0 +1,129 @@ +export type EditCommentPart = + | { type: 'text'; value: string } + | { type: 'section'; value: string } + | { type: 'link'; value: string } + +function collapseWhitespace(text: string): string { + return text.replace(/\s+/g, ' ').trim() +} + +function stripWikiMarkup(text: string): string { + let stripped = text + + stripped = stripped.replace(/<[^>]+>/g, '') + stripped = stripped.replace(/\{\|[\s\S]*?\|\}/g, '') + + let previous = '' + while (previous !== stripped) { + previous = stripped + stripped = stripped.replace(/\{\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}\}/g, '') + } + + stripped = stripped.replace(/'''''(.+?)'''''/g, '$1') + stripped = stripped.replace(/'''(.+?)'''/g, '$1') + stripped = stripped.replace(/''(.+?)''/g, '$1') + stripped = stripped.replace(/''/g, '') + stripped = stripped.replace(/^=+\s*(.*?)\s*=+$/gm, '$1') + stripped = stripped.replace(/^[*#:;]+/gm, '') + + return stripped +} + +function removeNonLinkWiki(text: string): string { + let cleaned = text + + cleaned = cleaned.replace(/\[\[(?:File|Image|Media|Category):[^\]]*]]/gi, '') + cleaned = cleaned.replace(/\[(?:https?:\/\/[^\s\]]+)(?:\s+([^\]]+))?\]/gi, '$1') + + return cleaned +} + +function appendTextPart(parts: EditCommentPart[], value: string): void { + const text = collapseWhitespace(stripWikiMarkup(value)) + if (!text.length) return + + const last = parts[parts.length - 1] + if (last?.type === 'text') { + last.value = collapseWhitespace(`${last.value}${text}`) + return + } + + parts.push({ type: 'text', value: text }) +} + +function wikiSummaryToParts(wiki: string): EditCommentPart[] { + const parts: EditCommentPart[] = [] + const text = removeNonLinkWiki(wiki) + const linkPattern = /\[\[([^|\]]+)\|([^\]]+)]]|\[\[([^\]]+)]]/g + + let lastIndex = 0 + let match: RegExpExecArray | null + + while ((match = linkPattern.exec(text)) !== null) { + appendTextPart(parts, text.slice(lastIndex, match.index)) + + const display = collapseWhitespace(stripWikiMarkup((match[2] ?? match[3]).trim())) + if (display.length) { + parts.push({ type: 'link', value: display }) + } + + lastIndex = match.index + match[0].length + } + + appendTextPart(parts, text.slice(lastIndex)) + return parts +} + +function hasSummaryContent(parts: EditCommentPart[]): boolean { + return parts.some((part) => part.value.trim().length > 0) +} + +function quoteSummaryParts(parts: EditCommentPart[]): EditCommentPart[] { + if (!hasSummaryContent(parts)) return [] + + if (parts[0]?.type === 'text') { + parts[0].value = parts[0].value.trimStart() + } + + return [{ type: 'text', value: '"' }, ...parts, { type: 'text', value: '"' }] +} + +/** Structured edit comment for card display (arrow, section label, quoted summary). */ +export function parseEditComment(raw: string): EditCommentPart[] { + const trimmed = raw.trim() + if (!trimmed.length) return [] + + const sectionMatch = trimmed.match(/^\/\*\s*(.*?)\s*\*\/\s*(.*)$/s) + if (sectionMatch) { + const section = collapseWhitespace(sectionMatch[1]) + const restParts = quoteSummaryParts(wikiSummaryToParts(sectionMatch[2])) + + if (!section.length && !restParts.length) { + return [] + } + + if (!section.length) { + return restParts + } + + const parts: EditCommentPart[] = [ + { type: 'text', value: '→' }, + { type: 'section', value: section }, + ] + + if (restParts.length) { + parts.push({ type: 'text', value: ':' }, { type: 'text', value: ' ' }, ...restParts) + } + + return parts + } + + return quoteSummaryParts(wikiSummaryToParts(trimmed)) +} + +/** Plain-text join (e.g. empty checks). */ +export function formatEditComment(raw: string): string { + return parseEditComment(raw) + .map((part) => part.value) + .join('') +} diff --git a/src/prototypes/morelike-playground/index.vue b/src/prototypes/morelike-playground/index.vue new file mode 100644 index 0000000..987a556 --- /dev/null +++ b/src/prototypes/morelike-playground/index.vue @@ -0,0 +1,564 @@ + + + + + diff --git a/src/prototypes/morelike-playground/morelikeMlt.ts b/src/prototypes/morelike-playground/morelikeMlt.ts new file mode 100644 index 0000000..7312a79 --- /dev/null +++ b/src/prototypes/morelike-playground/morelikeMlt.ts @@ -0,0 +1,151 @@ +export type MorelikeMltPreset = 'default' | 'custom' + +export type MorelikeSortOrder = 'relevance' | 'lastEdit' + +/** CirrusSearch more_like_this URL params (see Help:CirrusSearch#Morelike). */ +export interface MorelikeMltCustomSettings { + maxQueryTerms: number + minTermFreq: number + minDocFreq: number + maxDocFreq: number | null + minWordLength: number + maxWordLength: number | null + minimumShouldMatchPercent: number + fields: 'text' | 'title' | 'opening_text' + useFieldsOnly: boolean +} + +/** + * Extension defaults from CirrusSearch $wgCirrusSearchMoreLikeThisConfig + * (docs/settings.txt). The “Default” preset omits all cirrusMlt* params so the + * wiki uses these server-side — Custom only sends params that differ from this. + */ +export const CIRRUS_MLT_EXTENSION_DEFAULTS: MorelikeMltCustomSettings = { + maxQueryTerms: 25, + minTermFreq: 2, + minDocFreq: 2, + maxDocFreq: null, + minWordLength: 0, + maxWordLength: null, + minimumShouldMatchPercent: 30, + fields: 'text', + useFieldsOnly: false, +} + +/** Starting values for the Custom sliders (matches extension defaults). */ +export const DEFAULT_MLT_CUSTOM: MorelikeMltCustomSettings = { + ...CIRRUS_MLT_EXTENSION_DEFAULTS, +} + +export const MLT_PRESET_LABELS: Record = { + default: 'No overrides (wiki default)', + custom: 'Custom', +} + +export const SORT_ORDER_LABELS: Record = { + relevance: 'Most relevant', + lastEdit: 'Most recent edit', +} + +function clampInt(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, Math.round(value))) +} + +export function normalizeMltCustom(input: unknown): MorelikeMltCustomSettings { + if (typeof input !== 'object' || input === null) { + return { ...DEFAULT_MLT_CUSTOM } + } + + const record = input as Record + const fields = + record.fields === 'title' || record.fields === 'opening_text' || record.fields === 'text' + ? record.fields + : DEFAULT_MLT_CUSTOM.fields + + return { + maxQueryTerms: + typeof record.maxQueryTerms === 'number' + ? clampInt(record.maxQueryTerms, 1, 100) + : DEFAULT_MLT_CUSTOM.maxQueryTerms, + minTermFreq: + typeof record.minTermFreq === 'number' + ? clampInt(record.minTermFreq, 1, 20) + : DEFAULT_MLT_CUSTOM.minTermFreq, + minDocFreq: + typeof record.minDocFreq === 'number' + ? clampInt(record.minDocFreq, 1, 20) + : DEFAULT_MLT_CUSTOM.minDocFreq, + maxDocFreq: + typeof record.maxDocFreq === 'number' + ? clampInt(record.maxDocFreq, 1, 1_000_000) + : null, + minWordLength: + typeof record.minWordLength === 'number' + ? clampInt(record.minWordLength, 0, 20) + : DEFAULT_MLT_CUSTOM.minWordLength, + maxWordLength: + typeof record.maxWordLength === 'number' ? clampInt(record.maxWordLength, 1, 100) : null, + minimumShouldMatchPercent: + typeof record.minimumShouldMatchPercent === 'number' + ? clampInt(record.minimumShouldMatchPercent, 1, 100) + : DEFAULT_MLT_CUSTOM.minimumShouldMatchPercent, + fields, + useFieldsOnly: + typeof record.useFieldsOnly === 'boolean' + ? record.useFieldsOnly + : DEFAULT_MLT_CUSTOM.useFieldsOnly, + } +} + +function buildCustomMltParams(custom: MorelikeMltCustomSettings): Record { + const normalized = normalizeMltCustom(custom) + const defaults = CIRRUS_MLT_EXTENSION_DEFAULTS + const params: Record = {} + + if (normalized.maxQueryTerms !== defaults.maxQueryTerms) { + params.cirrusMltMaxQueryTerms = String(normalized.maxQueryTerms) + } + + if (normalized.minTermFreq !== defaults.minTermFreq) { + params.cirrusMltMinTermFreq = String(normalized.minTermFreq) + } + + if (normalized.minDocFreq !== defaults.minDocFreq) { + params.cirrusMltMinDocFreq = String(normalized.minDocFreq) + } + + if (normalized.minWordLength !== defaults.minWordLength) { + params.cirrusMltMinWordLength = String(normalized.minWordLength) + } + + if (normalized.minimumShouldMatchPercent !== defaults.minimumShouldMatchPercent) { + params.cirrusMltMinimumShouldMatch = `${normalized.minimumShouldMatchPercent}%` + } + + if (normalized.maxDocFreq !== null) { + params.cirrusMltMaxDocFreq = String(normalized.maxDocFreq) + } + + if (normalized.maxWordLength !== null) { + params.cirrusMltMaxWordLength = String(normalized.maxWordLength) + } + + if (normalized.useFieldsOnly) { + params.cirrusMltUseFields = 'true' + } + + if (normalized.useFieldsOnly || normalized.fields !== defaults.fields) { + params.cirrusMltFields = normalized.fields + } + + return params +} + +export function resolveMltParams( + preset: MorelikeMltPreset, + custom: MorelikeMltCustomSettings, +): Record { + if (preset === 'default') return {} + + return buildCustomMltParams(custom) +} diff --git a/src/prototypes/morelike-playground/morelikeStorage.ts b/src/prototypes/morelike-playground/morelikeStorage.ts new file mode 100644 index 0000000..61378af --- /dev/null +++ b/src/prototypes/morelike-playground/morelikeStorage.ts @@ -0,0 +1,118 @@ +import { + DEFAULT_MLT_CUSTOM, + normalizeMltCustom, + type MorelikeMltCustomSettings, + type MorelikeMltPreset, + type MorelikeSortOrder, +} from './morelikeMlt' + +export interface MorelikePlaygroundState { + seedText: string + resultLimit: number + sortOrder: MorelikeSortOrder + mltPreset: MorelikeMltPreset + mltCustom: MorelikeMltCustomSettings + classicNoboostlinks: boolean + interleave: boolean +} + +const STORAGE_KEY = 'protowiki-morelike-playground-v1' + +export const DEFAULT_MORELIKE_PLAYGROUND_STATE: MorelikePlaygroundState = { + seedText: '', + resultLimit: 20, + sortOrder: 'relevance', + mltPreset: 'default', + mltCustom: { ...DEFAULT_MLT_CUSTOM }, + classicNoboostlinks: true, + interleave: false, +} + +function clampResultLimit(value: number): number { + return Math.min(100, Math.max(1, Math.round(value))) +} + +function isSortOrder(value: unknown): value is MorelikeSortOrder { + return value === 'relevance' || value === 'lastEdit' +} + +function isMltPreset(value: unknown): value is MorelikeMltPreset { + return value === 'default' || value === 'custom' +} + +function normalizeMltPreset(value: unknown): MorelikeMltPreset { + if (isMltPreset(value)) return value + // Removed presets fall back to wiki default. + if (value === 'balancedTitles' || value === 'fewerTerms') return 'default' + return DEFAULT_MORELIKE_PLAYGROUND_STATE.mltPreset +} + +function normalizeState(input: unknown): MorelikePlaygroundState { + if (typeof input !== 'object' || input === null) { + return { ...DEFAULT_MORELIKE_PLAYGROUND_STATE, mltCustom: { ...DEFAULT_MLT_CUSTOM } } + } + + const record = input as Record + + return { + seedText: typeof record.seedText === 'string' ? record.seedText : DEFAULT_MORELIKE_PLAYGROUND_STATE.seedText, + resultLimit: + typeof record.resultLimit === 'number' + ? clampResultLimit(record.resultLimit) + : DEFAULT_MORELIKE_PLAYGROUND_STATE.resultLimit, + sortOrder: isSortOrder(record.sortOrder) + ? record.sortOrder + : DEFAULT_MORELIKE_PLAYGROUND_STATE.sortOrder, + mltPreset: normalizeMltPreset(record.mltPreset), + mltCustom: normalizeMltCustom(record.mltCustom), + classicNoboostlinks: + typeof record.classicNoboostlinks === 'boolean' + ? record.classicNoboostlinks + : DEFAULT_MORELIKE_PLAYGROUND_STATE.classicNoboostlinks, + interleave: + typeof record.interleave === 'boolean' + ? record.interleave + : DEFAULT_MORELIKE_PLAYGROUND_STATE.interleave, + } +} + +function clearStoredState(): void { + if (typeof window === 'undefined') return + + try { + window.localStorage.removeItem(STORAGE_KEY) + } catch { + // Private mode or blocked storage — ignore. + } +} + +export function loadMorelikePlaygroundState(): MorelikePlaygroundState { + if (typeof window === 'undefined') { + return { ...DEFAULT_MORELIKE_PLAYGROUND_STATE, mltCustom: { ...DEFAULT_MLT_CUSTOM } } + } + + try { + const stored = window.localStorage.getItem(STORAGE_KEY) + if (!stored) return { ...DEFAULT_MORELIKE_PLAYGROUND_STATE, mltCustom: { ...DEFAULT_MLT_CUSTOM } } + + const parsed: unknown = JSON.parse(stored) + const normalized = normalizeState(parsed) + persistMorelikePlaygroundState(normalized) + return normalized + } catch { + clearStoredState() + return { ...DEFAULT_MORELIKE_PLAYGROUND_STATE, mltCustom: { ...DEFAULT_MLT_CUSTOM } } + } +} + +export function persistMorelikePlaygroundState(state: MorelikePlaygroundState): void { + if (typeof window === 'undefined') return + + const normalized = normalizeState(state) + + try { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(normalized)) + } catch { + // Quota or private-mode failures — ignore. + } +}