diff --git a/package-lock.json b/package-lock.json index b09983c..3a6669e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1295,6 +1295,7 @@ "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -1344,6 +1345,7 @@ "integrity": "sha512-HDQH9O/47Dxi1ceDhBXdaldtf/WV9yRYMjbjCuNk3qnaTD564qwv61Y7+gTxwxRKzSrgO5uhtw584igXVuuZkA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.59.1", "@typescript-eslint/types": "8.59.1", @@ -1915,6 +1917,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2325,6 +2328,7 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -2385,6 +2389,7 @@ "integrity": "sha512-EFNNzu4HqtTRb5DJINpyd+u3bDdzETWDMpCzG+UBHz1tpsnMDCeOcf61u4Wy/cbXnMymK+MT9bjH7KcG1fItSw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", "natural-compare": "^1.4.0", @@ -3263,6 +3268,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -3410,6 +3416,7 @@ "integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -3592,6 +3599,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -3644,6 +3652,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -3759,6 +3768,7 @@ "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -3852,6 +3862,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -3871,6 +3882,7 @@ "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.33.tgz", "integrity": "sha512-1AgChhx5w3ALgT4oK3acm2Es/7jyZhWSVUfs3rOBlGQC0rjEDkS7G4lWlJJGGNQD+BV3reCwbQrOe1mPNwKHBQ==", "license": "MIT", + "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.33", "@vue/compiler-sfc": "3.5.33", @@ -3929,6 +3941,7 @@ "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", "license": "MIT", + "peer": true, "dependencies": { "@vue/devtools-api": "^6.6.4" }, diff --git a/src/lib/fetchWikimedia.ts b/src/lib/fetchWikimedia.ts new file mode 100644 index 0000000..370eb7d --- /dev/null +++ b/src/lib/fetchWikimedia.ts @@ -0,0 +1,167 @@ +import { fetchWithTimeout, type FetchWithTimeoutInit } from './fetchWithTimeout' + +const MAX_CONCURRENT_PER_HOST = 2 +const MIN_SPACING_MS = 150 +const MAX_RETRY_ATTEMPTS = 4 +const INITIAL_BACKOFF_MS = 500 +const MAX_BACKOFF_MS = 30_000 + +const QUEUED_HOST_SUFFIXES = [ + '.wikipedia.org', + '.wikidata.org', + 'commons.wikimedia.org', + 'wikimedia.org', + 'mediawiki.org', +] + +function hostFromInput(input: RequestInfo | URL): string | null { + try { + const url = + typeof input === 'string' + ? input + : input instanceof URL + ? input.href + : input.url + return new URL(url).hostname.toLowerCase() + } catch { + return null + } +} + +function shouldQueueHost(host: string): boolean { + if (host === 'api.wikimedia.org') return false + return QUEUED_HOST_SUFFIXES.some( + (suffix) => host === suffix.slice(1) || host.endsWith(suffix), + ) +} + +function sleep(ms: number, signal?: AbortSignal): Promise { + if (ms <= 0) return Promise.resolve() + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(signal.reason ?? new DOMException('Aborted', 'AbortError')) + return + } + const timer = setTimeout(resolve, ms) + if (signal) { + signal.addEventListener( + 'abort', + () => { + clearTimeout(timer) + reject(signal.reason ?? new DOMException('Aborted', 'AbortError')) + }, + { once: true }, + ) + } + }) +} + +function parseRetryAfterMs(header: string | null): number | null { + if (!header) return null + const seconds = Number(header) + if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1000 + const date = Date.parse(header) + if (Number.isNaN(date)) return null + return Math.max(0, date - Date.now()) +} + +function backoffMs(attempt: number): number { + const base = Math.min(INITIAL_BACKOFF_MS * 2 ** attempt, MAX_BACKOFF_MS) + const jitter = Math.floor(Math.random() * base * 0.25) + return base + jitter +} + +function isRetryableStatus(status: number): boolean { + return status === 429 || status >= 500 +} + +class HostQueue { + private chain: Promise = Promise.resolve() + private active = 0 + private lastStartMs = 0 + + enqueue(task: () => Promise): Promise { + const run = async (): Promise => { + while (this.active >= MAX_CONCURRENT_PER_HOST) { + await sleep(MIN_SPACING_MS) + } + + const waitMs = Math.max(0, MIN_SPACING_MS - (Date.now() - this.lastStartMs)) + if (waitMs > 0) await sleep(waitMs) + + this.active++ + this.lastStartMs = Date.now() + try { + return await task() + } finally { + this.active-- + } + } + + const result = this.chain.then(run, run) + this.chain = result.then( + () => undefined, + () => undefined, + ) + return result + } +} + +const hostQueues = new Map() + +function queueForHost(host: string): HostQueue { + let queue = hostQueues.get(host) + if (!queue) { + queue = new HostQueue() + hostQueues.set(host, queue) + } + return queue +} + +async function fetchWithRetry( + input: RequestInfo | URL, + init: FetchWithTimeoutInit, +): Promise { + const { signal } = init + let lastResponse: Response | undefined + + for (let attempt = 0; attempt < MAX_RETRY_ATTEMPTS; attempt++) { + if (signal?.aborted) { + throw signal.reason ?? new DOMException('Aborted', 'AbortError') + } + + const response = await fetchWithTimeout(input, init) + if (!isRetryableStatus(response.status)) { + return response + } + + lastResponse = response + const isLastAttempt = attempt >= MAX_RETRY_ATTEMPTS - 1 + if (isLastAttempt) break + + const retryAfterMs = parseRetryAfterMs(response.headers.get('Retry-After')) + const delayMs = retryAfterMs ?? backoffMs(attempt) + await sleep(delayMs, signal) + } + + return lastResponse! +} + +export type FetchWikimediaInit = FetchWithTimeoutInit + +/** Queued, retried fetch for Wikimedia API hosts. Other hosts pass through. */ +export async function fetchWikimedia( + input: RequestInfo | URL, + init: FetchWikimediaInit = {}, +): Promise { + const host = hostFromInput(input) + if (!host || !shouldQueueHost(host)) { + return fetchWithRetry(input, init) + } + return queueForHost(host).enqueue(() => fetchWithRetry(input, init)) +} + +/** Clear in-memory host queues (e.g. on reset). */ +export function clearWikimediaFetchQueues(): void { + hostQueues.clear() +} diff --git a/src/lib/fetchWithTimeout.ts b/src/lib/fetchWithTimeout.ts new file mode 100644 index 0000000..be9b50a --- /dev/null +++ b/src/lib/fetchWithTimeout.ts @@ -0,0 +1,43 @@ +const DEFAULT_TIMEOUT_MS = 7000 + +export interface FetchWithTimeoutInit extends RequestInit { + /** Abort the request after this many milliseconds. Defaults to 7000. */ + timeoutMs?: number +} + +/** + * `fetch` that aborts itself after `timeoutMs`, while still honouring a + * caller-supplied `signal` (navigation aborts). A timeout rejects with a + * `TimeoutError` so callers can distinguish it from a user-driven `AbortError`. + */ +export async function fetchWithTimeout( + input: RequestInfo | URL, + init: FetchWithTimeoutInit = {}, +): Promise { + const { timeoutMs = DEFAULT_TIMEOUT_MS, signal: externalSignal, ...rest } = init + + const controller = new AbortController() + + function onExternalAbort() { + controller.abort(externalSignal?.reason) + } + + if (externalSignal) { + if (externalSignal.aborted) { + controller.abort(externalSignal.reason) + } else { + externalSignal.addEventListener('abort', onExternalAbort, { once: true }) + } + } + + const timeout = setTimeout(() => { + controller.abort(new DOMException(`Request timed out after ${timeoutMs}ms`, 'TimeoutError')) + }, timeoutMs) + + try { + return await fetch(input, { ...rest, signal: controller.signal }) + } finally { + clearTimeout(timeout) + externalSignal?.removeEventListener('abort', onExternalAbort) + } +} diff --git a/src/lib/mapWithConcurrency.ts b/src/lib/mapWithConcurrency.ts new file mode 100644 index 0000000..a0b545d --- /dev/null +++ b/src/lib/mapWithConcurrency.ts @@ -0,0 +1,26 @@ +/** Run `fn` over `items` with at most `concurrency` workers in flight. */ +export async function mapWithConcurrency( + items: T[], + concurrency: number, + fn: (item: T) => Promise, + signal?: AbortSignal, +): Promise { + if (!items.length) return [] + + const results: R[] = new Array(items.length) + let nextIndex = 0 + + async function worker() { + while (true) { + const i = nextIndex++ + if (i >= items.length) break + if (signal?.aborted) throw new DOMException('Aborted', 'AbortError') + results[i] = await fn(items[i]) + } + } + + await Promise.all( + Array.from({ length: Math.min(concurrency, items.length) }, () => worker()), + ) + return results +} diff --git a/src/prototypes/example-hard-shadow-border/HardShadowTabTrack.vue b/src/prototypes/example-hard-shadow-border/HardShadowTabTrack.vue new file mode 100644 index 0000000..a9008af --- /dev/null +++ b/src/prototypes/example-hard-shadow-border/HardShadowTabTrack.vue @@ -0,0 +1,256 @@ + + + + + diff --git a/src/prototypes/example-hard-shadow-border/OptionSection.vue b/src/prototypes/example-hard-shadow-border/OptionSection.vue new file mode 100644 index 0000000..37f20ee --- /dev/null +++ b/src/prototypes/example-hard-shadow-border/OptionSection.vue @@ -0,0 +1,131 @@ + + + + + diff --git a/src/prototypes/example-hard-shadow-border/index.vue b/src/prototypes/example-hard-shadow-border/index.vue new file mode 100644 index 0000000..5dca623 --- /dev/null +++ b/src/prototypes/example-hard-shadow-border/index.vue @@ -0,0 +1,232 @@ + + + + + diff --git a/src/prototypes/musical-group/BookmarkIcon.vue b/src/prototypes/musical-group/BookmarkIcon.vue new file mode 100644 index 0000000..b9bb985 --- /dev/null +++ b/src/prototypes/musical-group/BookmarkIcon.vue @@ -0,0 +1,43 @@ + + + + + diff --git a/src/prototypes/musical-group/ImageCarousel.vue b/src/prototypes/musical-group/ImageCarousel.vue new file mode 100644 index 0000000..ad1c1ab --- /dev/null +++ b/src/prototypes/musical-group/ImageCarousel.vue @@ -0,0 +1,179 @@ + + + + + diff --git a/src/prototypes/musical-group/MusicalGroupArticle.vue b/src/prototypes/musical-group/MusicalGroupArticle.vue new file mode 100644 index 0000000..8bc72b7 --- /dev/null +++ b/src/prototypes/musical-group/MusicalGroupArticle.vue @@ -0,0 +1,531 @@ + + + + + diff --git a/src/prototypes/musical-group/MusicalGroupFacts.vue b/src/prototypes/musical-group/MusicalGroupFacts.vue new file mode 100644 index 0000000..87593b2 --- /dev/null +++ b/src/prototypes/musical-group/MusicalGroupFacts.vue @@ -0,0 +1,107 @@ + + + + + diff --git a/src/prototypes/musical-group/MusicalGroupHome.vue b/src/prototypes/musical-group/MusicalGroupHome.vue new file mode 100644 index 0000000..2bd4670 --- /dev/null +++ b/src/prototypes/musical-group/MusicalGroupHome.vue @@ -0,0 +1,768 @@ + + + + + diff --git a/src/prototypes/musical-group/MusicalGroupLinks.vue b/src/prototypes/musical-group/MusicalGroupLinks.vue new file mode 100644 index 0000000..8f80f76 --- /dev/null +++ b/src/prototypes/musical-group/MusicalGroupLinks.vue @@ -0,0 +1,101 @@ + + + + + diff --git a/src/prototypes/musical-group/MusicalGroupOverview.vue b/src/prototypes/musical-group/MusicalGroupOverview.vue new file mode 100644 index 0000000..0cb4a90 --- /dev/null +++ b/src/prototypes/musical-group/MusicalGroupOverview.vue @@ -0,0 +1,150 @@ + + + + + diff --git a/src/prototypes/musical-group/MusicalGroupOverviewArticleCard.vue b/src/prototypes/musical-group/MusicalGroupOverviewArticleCard.vue new file mode 100644 index 0000000..383e055 --- /dev/null +++ b/src/prototypes/musical-group/MusicalGroupOverviewArticleCard.vue @@ -0,0 +1,66 @@ + + + diff --git a/src/prototypes/musical-group/MusicalGroupOverviewEditOpportunityCard.vue b/src/prototypes/musical-group/MusicalGroupOverviewEditOpportunityCard.vue new file mode 100644 index 0000000..f06d4f2 --- /dev/null +++ b/src/prototypes/musical-group/MusicalGroupOverviewEditOpportunityCard.vue @@ -0,0 +1,37 @@ + + + diff --git a/src/prototypes/musical-group/MusicalGroupOverviewImagesCard.vue b/src/prototypes/musical-group/MusicalGroupOverviewImagesCard.vue new file mode 100644 index 0000000..5876009 --- /dev/null +++ b/src/prototypes/musical-group/MusicalGroupOverviewImagesCard.vue @@ -0,0 +1,46 @@ + + + diff --git a/src/prototypes/musical-group/MusicalGroupOverviewLatestEditCard.vue b/src/prototypes/musical-group/MusicalGroupOverviewLatestEditCard.vue new file mode 100644 index 0000000..430d7f3 --- /dev/null +++ b/src/prototypes/musical-group/MusicalGroupOverviewLatestEditCard.vue @@ -0,0 +1,98 @@ + + + diff --git a/src/prototypes/musical-group/MusicalGroupOverviewRelatedCard.vue b/src/prototypes/musical-group/MusicalGroupOverviewRelatedCard.vue new file mode 100644 index 0000000..667b7e2 --- /dev/null +++ b/src/prototypes/musical-group/MusicalGroupOverviewRelatedCard.vue @@ -0,0 +1,61 @@ + + + diff --git a/src/prototypes/musical-group/MusicalGroupOverviewSnippetCard.vue b/src/prototypes/musical-group/MusicalGroupOverviewSnippetCard.vue new file mode 100644 index 0000000..51eae13 --- /dev/null +++ b/src/prototypes/musical-group/MusicalGroupOverviewSnippetCard.vue @@ -0,0 +1,58 @@ + + + diff --git a/src/prototypes/musical-group/MusicalGroupPhotos.vue b/src/prototypes/musical-group/MusicalGroupPhotos.vue new file mode 100644 index 0000000..f83fc26 --- /dev/null +++ b/src/prototypes/musical-group/MusicalGroupPhotos.vue @@ -0,0 +1,202 @@ + + + + + diff --git a/src/prototypes/musical-group/MusicalGroupScreen.vue b/src/prototypes/musical-group/MusicalGroupScreen.vue new file mode 100644 index 0000000..b48c208 --- /dev/null +++ b/src/prototypes/musical-group/MusicalGroupScreen.vue @@ -0,0 +1,280 @@ + + + + + diff --git a/src/prototypes/musical-group/MusicalGroupSearch.vue b/src/prototypes/musical-group/MusicalGroupSearch.vue new file mode 100644 index 0000000..34a1411 --- /dev/null +++ b/src/prototypes/musical-group/MusicalGroupSearch.vue @@ -0,0 +1,213 @@ + + + + + diff --git a/src/prototypes/musical-group/MusicalGroupTabs.vue b/src/prototypes/musical-group/MusicalGroupTabs.vue new file mode 100644 index 0000000..d0262d6 --- /dev/null +++ b/src/prototypes/musical-group/MusicalGroupTabs.vue @@ -0,0 +1,185 @@ + + + + + diff --git a/src/prototypes/musical-group/MusicalGroupTitleRow.vue b/src/prototypes/musical-group/MusicalGroupTitleRow.vue new file mode 100644 index 0000000..e449353 --- /dev/null +++ b/src/prototypes/musical-group/MusicalGroupTitleRow.vue @@ -0,0 +1,64 @@ + + + diff --git a/src/prototypes/musical-group/assets/bookmark-filled.svg b/src/prototypes/musical-group/assets/bookmark-filled.svg new file mode 100644 index 0000000..0681c34 --- /dev/null +++ b/src/prototypes/musical-group/assets/bookmark-filled.svg @@ -0,0 +1,8 @@ + diff --git a/src/prototypes/musical-group/assets/bookmark-outline.svg b/src/prototypes/musical-group/assets/bookmark-outline.svg new file mode 100644 index 0000000..58adadf --- /dev/null +++ b/src/prototypes/musical-group/assets/bookmark-outline.svg @@ -0,0 +1,8 @@ + diff --git a/src/prototypes/musical-group/assets/wikipedia-w.svg b/src/prototypes/musical-group/assets/wikipedia-w.svg new file mode 100644 index 0000000..8717fda --- /dev/null +++ b/src/prototypes/musical-group/assets/wikipedia-w.svg @@ -0,0 +1,6 @@ + diff --git a/src/prototypes/musical-group/components/WikitaActivityTabPanel.vue b/src/prototypes/musical-group/components/WikitaActivityTabPanel.vue new file mode 100644 index 0000000..7ebac65 --- /dev/null +++ b/src/prototypes/musical-group/components/WikitaActivityTabPanel.vue @@ -0,0 +1,265 @@ + + + + + diff --git a/src/prototypes/musical-group/components/WikitaButton.vue b/src/prototypes/musical-group/components/WikitaButton.vue new file mode 100644 index 0000000..a81e574 --- /dev/null +++ b/src/prototypes/musical-group/components/WikitaButton.vue @@ -0,0 +1,117 @@ + + + + + diff --git a/src/prototypes/musical-group/components/WikitaCardItem.vue b/src/prototypes/musical-group/components/WikitaCardItem.vue new file mode 100644 index 0000000..bf40714 --- /dev/null +++ b/src/prototypes/musical-group/components/WikitaCardItem.vue @@ -0,0 +1,417 @@ + + + + + diff --git a/src/prototypes/musical-group/components/WikitaCardNotice.vue b/src/prototypes/musical-group/components/WikitaCardNotice.vue new file mode 100644 index 0000000..3a5262a --- /dev/null +++ b/src/prototypes/musical-group/components/WikitaCardNotice.vue @@ -0,0 +1,65 @@ + + + + + diff --git a/src/prototypes/musical-group/components/WikitaCardScrollTable.vue b/src/prototypes/musical-group/components/WikitaCardScrollTable.vue new file mode 100644 index 0000000..643ea91 --- /dev/null +++ b/src/prototypes/musical-group/components/WikitaCardScrollTable.vue @@ -0,0 +1,164 @@ + + + + + diff --git a/src/prototypes/musical-group/components/WikitaCardSidebar.vue b/src/prototypes/musical-group/components/WikitaCardSidebar.vue new file mode 100644 index 0000000..d60381e --- /dev/null +++ b/src/prototypes/musical-group/components/WikitaCardSidebar.vue @@ -0,0 +1,215 @@ + + + + + diff --git a/src/prototypes/musical-group/components/WikitaCardTable.vue b/src/prototypes/musical-group/components/WikitaCardTable.vue new file mode 100644 index 0000000..c5fa908 --- /dev/null +++ b/src/prototypes/musical-group/components/WikitaCardTable.vue @@ -0,0 +1,223 @@ + + + + + diff --git a/src/prototypes/musical-group/components/WikitaCardWrapper.vue b/src/prototypes/musical-group/components/WikitaCardWrapper.vue new file mode 100644 index 0000000..74c6dae --- /dev/null +++ b/src/prototypes/musical-group/components/WikitaCardWrapper.vue @@ -0,0 +1,169 @@ + + + + + diff --git a/src/prototypes/musical-group/components/WikitaChromeHeader.vue b/src/prototypes/musical-group/components/WikitaChromeHeader.vue new file mode 100644 index 0000000..8038195 --- /dev/null +++ b/src/prototypes/musical-group/components/WikitaChromeHeader.vue @@ -0,0 +1,484 @@ + + + + + + + + diff --git a/src/prototypes/musical-group/components/WikitaContributeTabPanel.vue b/src/prototypes/musical-group/components/WikitaContributeTabPanel.vue new file mode 100644 index 0000000..ea48535 --- /dev/null +++ b/src/prototypes/musical-group/components/WikitaContributeTabPanel.vue @@ -0,0 +1,217 @@ + + + + + diff --git a/src/prototypes/musical-group/components/WikitaExternalLink.vue b/src/prototypes/musical-group/components/WikitaExternalLink.vue new file mode 100644 index 0000000..356278f --- /dev/null +++ b/src/prototypes/musical-group/components/WikitaExternalLink.vue @@ -0,0 +1,101 @@ + + + + + diff --git a/src/prototypes/musical-group/components/WikitaFloatingNav.vue b/src/prototypes/musical-group/components/WikitaFloatingNav.vue new file mode 100644 index 0000000..11bde81 --- /dev/null +++ b/src/prototypes/musical-group/components/WikitaFloatingNav.vue @@ -0,0 +1,80 @@ + + + + + diff --git a/src/prototypes/musical-group/components/WikitaHomeSection.vue b/src/prototypes/musical-group/components/WikitaHomeSection.vue new file mode 100644 index 0000000..286887e --- /dev/null +++ b/src/prototypes/musical-group/components/WikitaHomeSection.vue @@ -0,0 +1,96 @@ + + + + + diff --git a/src/prototypes/musical-group/components/WikitaHomeTabs.vue b/src/prototypes/musical-group/components/WikitaHomeTabs.vue new file mode 100644 index 0000000..1893cd6 --- /dev/null +++ b/src/prototypes/musical-group/components/WikitaHomeTabs.vue @@ -0,0 +1,147 @@ + + + + + diff --git a/src/prototypes/musical-group/components/WikitaIcon.vue b/src/prototypes/musical-group/components/WikitaIcon.vue new file mode 100644 index 0000000..0bfff45 --- /dev/null +++ b/src/prototypes/musical-group/components/WikitaIcon.vue @@ -0,0 +1,94 @@ + + + + + diff --git a/src/prototypes/musical-group/components/WikitaTitle.vue b/src/prototypes/musical-group/components/WikitaTitle.vue new file mode 100644 index 0000000..42a2892 --- /dev/null +++ b/src/prototypes/musical-group/components/WikitaTitle.vue @@ -0,0 +1,186 @@ + + + + + diff --git a/src/prototypes/musical-group/data/bookmarks.ts b/src/prototypes/musical-group/data/bookmarks.ts new file mode 100644 index 0000000..6ab15d4 --- /dev/null +++ b/src/prototypes/musical-group/data/bookmarks.ts @@ -0,0 +1,80 @@ +import { normalizeQid } from './wikidataApi' + +const STORAGE_KEY = 'musical-group-bookmarks' + +export interface BookmarkEntry { + id: string + /** Epoch milliseconds the bookmark was saved. Legacy bookmarks read back as `0`. */ + savedAt: number +} + +function parseEntry(value: unknown): BookmarkEntry | null { + // Legacy format: a bare QID string with no timestamp. + if (typeof value === 'string') { + const id = normalizeQid(value) ?? value + return { id, savedAt: 0 } + } + + if (typeof value === 'object' && value !== null) { + const record = value as Record + if (typeof record.id !== 'string') return null + const id = normalizeQid(record.id) ?? record.id + const savedAt = typeof record.savedAt === 'number' ? record.savedAt : 0 + return { id, savedAt } + } + + return null +} + +function readEntries(): BookmarkEntry[] { + if (typeof window === 'undefined') return [] + try { + const raw = window.localStorage.getItem(STORAGE_KEY) + if (!raw) return [] + const parsed = JSON.parse(raw) as unknown + if (!Array.isArray(parsed)) return [] + + const seen = new Set() + const entries: BookmarkEntry[] = [] + for (const item of parsed) { + const entry = parseEntry(item) + if (!entry || seen.has(entry.id)) continue + seen.add(entry.id) + entries.push(entry) + } + return entries + } catch { + return [] + } +} + +function writeEntries(entries: BookmarkEntry[]): void { + if (typeof window === 'undefined') return + try { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(entries)) + } catch { + // Ignore quota / private mode failures. + } +} + +/** All bookmarks, newest-saved first. */ +export function listBookmarks(): BookmarkEntry[] { + return readEntries().sort((a, b) => b.savedAt - a.savedAt) +} + +export function isBookmarked(id: string): boolean { + return readEntries().some((entry) => entry.id === id) +} + +export function toggleBookmark(id: string): boolean { + const entries = readEntries() + const index = entries.findIndex((entry) => entry.id === id) + if (index >= 0) { + entries.splice(index, 1) + writeEntries(entries) + return false + } + entries.push({ id, savedAt: Date.now() }) + writeEntries(entries) + return true +} diff --git a/src/prototypes/musical-group/data/cacheKeys.ts b/src/prototypes/musical-group/data/cacheKeys.ts new file mode 100644 index 0000000..e74ae2f --- /dev/null +++ b/src/prototypes/musical-group/data/cacheKeys.ts @@ -0,0 +1,33 @@ +import { listBookmarks } from './bookmarks' +import type { HomeSavedItem } from './types' + +/** Stable fingerprint of the saved-pages library. Changes on add/remove/re-save. */ +export function bookmarksKey(): string { + return listBookmarks() + .map((entry) => `${entry.id}:${entry.savedAt}`) + .sort() + .join('|') +} + +/** Feed dependency key from saved item ids — changes only on add/remove. */ +export function savedPagesListKey(items: HomeSavedItem[]): string { + return [...items] + .map((item) => item.id) + .sort() + .join(',') +} + +/** UTC calendar day for daily feeds, e.g. `20260701`. */ +export function utcDayKey(date = new Date()): string { + const yyyy = String(date.getUTCFullYear()) + const mm = String(date.getUTCMonth() + 1).padStart(2, '0') + const dd = String(date.getUTCDate()).padStart(2, '0') + return `${yyyy}${mm}${dd}` +} + +export function utcDayParts(date = new Date()): { yyyy: string; mm: string; dd: string; key: string } { + const yyyy = String(date.getUTCFullYear()) + const mm = String(date.getUTCMonth() + 1).padStart(2, '0') + const dd = String(date.getUTCDate()).padStart(2, '0') + return { yyyy, mm, dd, key: `${yyyy}${mm}${dd}` } +} diff --git a/src/prototypes/musical-group/data/carouselLayout.ts b/src/prototypes/musical-group/data/carouselLayout.ts new file mode 100644 index 0000000..e606e1e --- /dev/null +++ b/src/prototypes/musical-group/data/carouselLayout.ts @@ -0,0 +1,61 @@ +import type { CarouselImage } from './types' + +/** Mirrors ImageCarousel.vue layout — not live DOM measurement. */ +export const CAROUSEL_VIEWPORT_WIDTH = 412 +export const CAROUSEL_GAP = 10 +export const CAROUSEL_LEADING_INSET = 8 +export const CAROUSEL_SLIDE_HEIGHT = 154 + +export function carouselSlideWidth(image: CarouselImage): number { + if (image.width <= 0 || image.height <= 0) return CAROUSEL_SLIDE_HEIGHT + return Math.round((CAROUSEL_SLIDE_HEIGHT * image.width) / image.height) +} + +/** First slide whose start x is fully off-screen (start >= viewport width). */ +export function firstOffScreenCarouselIndex(images: CarouselImage[]): number | null { + if (!images.length) return null + + let x = CAROUSEL_LEADING_INSET + + for (let i = 0; i < images.length; i++) { + if (i > 0) x += CAROUSEL_GAP + if (x >= CAROUSEL_VIEWPORT_WIDTH) return i + x += carouselSlideWidth(images[i]) + } + + return null +} + +export function offScreenCarouselThumbnailUrl(images: CarouselImage[]): string | undefined { + const index = firstOffScreenCarouselIndex(images) + if (index === null) return undefined + + const url = images[index]?.url + if (!url) return undefined + + return commonsThumbnailUrl(url) +} + +function commonsThumbnailUrl(url: string): string { + if (url.includes('commons.wikimedia.org/wiki/Special:FilePath/')) { + const separator = url.includes('?') ? '&' : '?' + return `${url}${separator}width=72` + } + + return url +} + +/** Prefer an off-screen carousel slide; otherwise the first Commons image (for overview cards). */ +export function overviewCarouselThumbnailUrl( + images: CarouselImage[], + options: { preferNonPrimary?: boolean } = {}, +): string | undefined { + const offScreen = offScreenCarouselThumbnailUrl(images) + if (offScreen) return offScreen + + const index = options.preferNonPrimary && images.length > 1 ? 1 : 0 + const url = images[index]?.url + if (!url) return undefined + + return commonsThumbnailUrl(url) +} diff --git a/src/prototypes/musical-group/data/commonsImages.ts b/src/prototypes/musical-group/data/commonsImages.ts new file mode 100644 index 0000000..385ea16 --- /dev/null +++ b/src/prototypes/musical-group/data/commonsImages.ts @@ -0,0 +1,646 @@ +import { wikimediaApiFetchHeaders } from '@/config' + +import { fetchWikimedia } from '@/lib/fetchWikimedia' +import type { CarouselImage } from './types' + +const COMMONS_API = 'https://commons.wikimedia.org/w/api.php' +const THUMB_WIDTH = 800 +const GRID_THUMB_WIDTH = 320 +const MAX_CAROUSEL_IMAGES = 5 +const SEARCH_LIMIT = 20 +const CATEGORY_MEMBER_LIMIT = 50 +/** How many candidate titles to resolve image details for in one request. */ +const CANDIDATE_LIMIT = 30 +const BATCH_TITLE_LIMIT = 24 +/** Images resolved per infinite-scroll batch in the photos grid. */ +const GRID_BATCH_TITLE_LIMIT = 12 + +export interface CommonsCategoryCount { + /** Number of files directly in the category. */ + files: number + /** Number of top-level subcategories ("collections"). */ + subcats: number +} + +interface CommonsImage { + title: string + url: string + width: number + height: number +} + +const countInFlight = new Map>() +const countResolved = new Map() + +export function clearCommonsImageCache(): void { + countInFlight.clear() + countResolved.clear() +} + +function normalizeCategoryKey(name: string): string { + return name.replace(/^Category:/i, '').trim().toLowerCase() +} + +/** + * Canonical `File:` title. MediaWiki treats spaces and underscores as + * equivalent, so normalise underscores to spaces for reliable dedup + lookup + * (e.g. matching a Wikidata P18 value against an API response title). + */ +function normalizeFileTitle(title: string): string { + const withoutPrefix = title.trim().replace(/^File:/i, '').replace(/_/g, ' ').trim() + return `File:${withoutPrefix}` +} + +async function commonsGet(params: Record, signal?: AbortSignal): Promise { + const url = new URL(COMMONS_API) + url.searchParams.set('format', 'json') + url.searchParams.set('origin', '*') + for (const [key, value] of Object.entries(params)) { + url.searchParams.set(key, value) + } + + const response = await fetchWikimedia(url, { + signal, + headers: wikimediaApiFetchHeaders('musical-group-commons'), + }) + if (!response.ok) { + throw new Error(`Commons API error: ${response.status}`) + } + return response.json() +} + +interface ImageInfoEntry { + thumburl?: string + url?: string + width?: number + height?: number + mime?: string +} + +interface ImageInfoPage { + title?: string + imageinfo?: ImageInfoEntry[] +} + +function isRasterImage(info: ImageInfoEntry): boolean { + const mime = info.mime?.toLowerCase() ?? '' + if (!mime.startsWith('image/')) return false + return (info.width ?? 0) > 0 && (info.height ?? 0) > 0 +} + +function imageFromPage(page: ImageInfoPage): CommonsImage | null { + const info = page.imageinfo?.[0] + if (!page.title || !info || !isRasterImage(info)) return null + + const url = info.thumburl ?? info.url + if (!url || url.includes('/file-type-icons/')) return null + + return { + title: normalizeFileTitle(page.title), + url, + width: info.width ?? 0, + height: info.height ?? 0, + } +} + +/** Resolve url + size for a list of file titles, preserving the requested order. */ +async function fetchImageDetails( + titles: string[], + thumbWidth: number, + signal?: AbortSignal, +): Promise { + if (titles.length === 0) return [] + + const data = (await commonsGet( + { + action: 'query', + titles: titles.join('|'), + prop: 'imageinfo', + iiprop: 'url|size|mime', + iiurlwidth: String(thumbWidth), + }, + signal, + )) as { query?: { pages?: Record } } + + const byTitle = new Map() + for (const page of Object.values(data.query?.pages ?? {})) { + const image = imageFromPage(page) + if (image) byTitle.set(image.title, image) + } + + return titles + .map((title) => byTitle.get(normalizeFileTitle(title))) + .filter((image): image is CommonsImage => Boolean(image)) +} + +interface SearchPageResult { + titles: string[] + nextOffset?: number +} + +/** Relevance-ordered file-title search across Commons (one page). */ +async function searchCommonsFilesPage( + query: string, + offset: number, + signal?: AbortSignal, +): Promise { + if (!query.trim()) return { titles: [] } + + const params: Record = { + action: 'query', + generator: 'search', + gsrnamespace: '6', + gsrsearch: query, + gsrlimit: String(SEARCH_LIMIT), + gsrsort: 'relevance', + prop: 'info', + } + if (offset > 0) params.gsroffset = String(offset) + + const data = (await commonsGet(params, signal)) as { + query?: { pages?: Record } + continue?: { gsroffset?: string } + } + + const titles = Object.values(data.query?.pages ?? {}) + .sort((a, b) => (a.index ?? 0) - (b.index ?? 0)) + .map((page) => page.title) + .filter((title): title is string => Boolean(title)) + .map(normalizeFileTitle) + + const nextOffset = data.continue?.gsroffset + ? Number.parseInt(data.continue.gsroffset, 10) + : undefined + + return { titles, nextOffset } +} + +interface CategoryMembersPageResult { + titles: string[] + continueToken?: string +} + +/** Direct file members of a category (alphabetical) — one page. */ +async function fetchCategoryMemberTitlesPage( + categoryName: string, + continueToken?: string, + signal?: AbortSignal, +): Promise { + const params: Record = { + action: 'query', + list: 'categorymembers', + cmtitle: `Category:${categoryName}`, + cmtype: 'file', + cmlimit: String(CATEGORY_MEMBER_LIMIT), + } + if (continueToken) params.cmcontinue = continueToken + + const data = (await commonsGet(params, signal)) as { + query?: { categorymembers?: Array<{ title?: string }> } + continue?: { cmcontinue?: string } + } + + const titles = (data.query?.categorymembers ?? []) + .map((member) => member.title) + .filter((title): title is string => Boolean(title)) + .map(normalizeFileTitle) + + return { + titles, + continueToken: data.continue?.cmcontinue, + } +} + +async function resolveCommonsCategoryCount( + categoryName: string, + signal?: AbortSignal, +): Promise { + const data = (await commonsGet( + { + action: 'query', + prop: 'categoryinfo', + titles: `Category:${categoryName}`, + }, + signal, + )) as { query?: { pages?: Record } } + + const page = Object.values(data.query?.pages ?? {})[0] + const info = page?.categoryinfo + return { + files: info?.files ?? 0, + subcats: info?.subcats ?? 0, + } +} + +/** Memoized, fault-tolerant direct file count for a Commons category. */ +export function getCommonsCategoryCount( + categoryName: string, + signal?: AbortSignal, +): Promise { + const key = normalizeCategoryKey(categoryName) + const cached = countResolved.get(key) + if (cached) return Promise.resolve(cached) + + let pending = countInFlight.get(key) + if (!pending) { + pending = resolveCommonsCategoryCount(categoryName, signal) + .then((result) => { + countResolved.set(key, result) + return result + }) + .catch(() => ({ files: 0, subcats: 0 })) + .finally(() => { + countInFlight.delete(key) + }) + countInFlight.set(key, pending) + } + + return pending +} + +/** + * Photos count label. Categories with subcategories are described by their + * top-level "collections" ("N+ collections"); flat categories show their direct + * file count ("N items"). + */ +export function formatCommonsPhotosLabel(files: number, subcats: number): string { + if (subcats > 0) return `${subcats.toLocaleString()}+ collections` + return `${files.toLocaleString()} ${files === 1 ? 'item' : 'items'}` +} + +export function commonsImageCountFromCategory( + info: CommonsCategoryCount, +): { count: number; capped: boolean } | undefined { + if (info.files === 0 && info.subcats === 0) return undefined + const capped = info.subcats > 0 + return { + count: capped ? info.subcats : info.files, + capped, + } +} + +export function formatCarouselOverflowCount(count: number, capped: boolean): string { + return capped ? `${count.toLocaleString()}+` : count.toLocaleString() +} + +/** P373 claim, else entity label — same fallback as the overview Images card. */ +export function resolveCommonsCategory(data: { + commonsCategory?: string + label: string +}): string | undefined { + const fromClaim = data.commonsCategory?.trim() + if (fromClaim) return fromClaim + const fromLabel = data.label.trim() + return fromLabel.length ? fromLabel : undefined +} + +export interface CommonsPhotosFeedSource { + imageFilename: string | null + commonsCategory: string | null + label: string +} + +export type CommonsPhotosFeedPhase = + | 'inCategory' + | 'deepCategory' + | 'general' + | 'categoryMembers' + | 'done' + +export interface CommonsPhotosFeedCursor { + phase: CommonsPhotosFeedPhase + /** Titles queued during cursor init, consumed before paginated phases. */ + bufferedTitles: string[] + bufferedIndex: number + inCategoryOffset: number + deepCategoryOffset: number + generalOffset: number + categoryContinue?: string + category: string + labelQuery: string + inCategoryQuery: string + deepCategoryQuery: string + generalQuery: string + /** When true, skip unrestricted label search (Images tab — category files only). */ + categoryOnly: boolean +} + +function escapeSearchTerm(value: string): string { + return value.replace(/"/g, '') +} + +function initialPhotosFeedPhase( + category: string, + labelQuery: string, + inCategoryQuery: string, + deepCategoryQuery: string, + categoryOnly: boolean, +): CommonsPhotosFeedPhase { + if (inCategoryQuery) return 'inCategory' + if (!categoryOnly && labelQuery) return 'general' + if (deepCategoryQuery) return 'deepCategory' + if (category) return 'categoryMembers' + return 'done' +} + +export function createCommonsPhotosFeedCursor( + source: CommonsPhotosFeedSource, + options: { categoryOnly?: boolean } = {}, +): CommonsPhotosFeedCursor { + const categoryOnly = options.categoryOnly ?? false + const category = + resolveCommonsCategory({ + commonsCategory: source.commonsCategory ?? undefined, + label: source.label, + }) ?? '' + const labelQuery = source.label.trim() + const inCategoryQuery = + category && labelQuery ? `${labelQuery} incategory:"${escapeSearchTerm(category)}"` : '' + const deepCategoryQuery = category ? `deepcategory:"${escapeSearchTerm(category)}"` : '' + + const bufferedTitles: string[] = [] + if (source.imageFilename) { + bufferedTitles.push(normalizeFileTitle(source.imageFilename)) + } + + return { + phase: initialPhotosFeedPhase( + category, + labelQuery, + inCategoryQuery, + deepCategoryQuery, + categoryOnly, + ), + bufferedTitles, + bufferedIndex: 0, + inCategoryOffset: 0, + deepCategoryOffset: 0, + generalOffset: 0, + categoryContinue: undefined, + category, + labelQuery, + inCategoryQuery, + deepCategoryQuery, + generalQuery: labelQuery, + categoryOnly, + } +} + +function advancePhotosFeedPhase(cursor: CommonsPhotosFeedCursor): CommonsPhotosFeedPhase { + if (cursor.phase === 'inCategory') { + if (!cursor.categoryOnly && cursor.generalQuery) return 'general' + if (cursor.deepCategoryQuery) return 'deepCategory' + if (cursor.category) return 'categoryMembers' + return 'done' + } + if (cursor.phase === 'general') { + if (cursor.deepCategoryQuery) return 'deepCategory' + if (cursor.category) return 'categoryMembers' + return 'done' + } + return 'done' +} + +function cursorHasPendingTitles(cursor: CommonsPhotosFeedCursor): boolean { + return cursor.bufferedIndex < cursor.bufferedTitles.length || cursor.phase !== 'done' +} + +async function pullCommonsPhotoTitles( + cursor: CommonsPhotosFeedCursor, + seenTitles: ReadonlySet, + count: number, + signal?: AbortSignal, +): Promise<{ titles: string[]; cursor: CommonsPhotosFeedCursor }> { + const titles: string[] = [] + const batchSeen = new Set() + let next = { ...cursor } + + function tryQueue(title: string) { + const normalized = normalizeFileTitle(title) + if (seenTitles.has(normalized) || batchSeen.has(normalized)) return + batchSeen.add(normalized) + titles.push(normalized) + } + + while (titles.length < count && cursorHasPendingTitles(next)) { + while (next.bufferedIndex < next.bufferedTitles.length && titles.length < count) { + tryQueue(next.bufferedTitles[next.bufferedIndex]) + next = { ...next, bufferedIndex: next.bufferedIndex + 1 } + } + + if (titles.length >= count) break + if (next.phase === 'done') break + + if (next.phase === 'inCategory') { + const page = await searchCommonsFilesPage(next.inCategoryQuery, next.inCategoryOffset, signal) + for (const title of page.titles) tryQueue(title) + if (page.nextOffset !== undefined) { + next = { ...next, inCategoryOffset: page.nextOffset } + } else { + next = { ...next, phase: advancePhotosFeedPhase(next) } + } + continue + } + + if (next.phase === 'general') { + const page = await searchCommonsFilesPage(next.generalQuery, next.generalOffset, signal) + for (const title of page.titles) tryQueue(title) + if (page.nextOffset !== undefined) { + next = { ...next, generalOffset: page.nextOffset } + } else { + next = { ...next, phase: advancePhotosFeedPhase(next) } + } + continue + } + + if (next.phase === 'deepCategory') { + const page = await searchCommonsFilesPage( + next.deepCategoryQuery, + next.deepCategoryOffset, + signal, + ) + for (const title of page.titles) tryQueue(title) + if (page.nextOffset !== undefined) { + next = { ...next, deepCategoryOffset: page.nextOffset } + } else { + next = { ...next, phase: 'done' } + } + continue + } + + if (next.phase === 'categoryMembers') { + const page = await fetchCategoryMemberTitlesPage( + next.category, + next.categoryContinue, + signal, + ) + for (const title of page.titles) tryQueue(title) + if (page.continueToken) { + next = { ...next, categoryContinue: page.continueToken } + } else { + next = { ...next, phase: 'done' } + } + } + } + + return { titles, cursor: next } +} + +function commonsImageToCarousel(image: CommonsImage): CarouselImage { + return { + url: image.url, + width: image.width, + height: image.height, + title: image.title, + } +} + +export interface FetchCommonsPhotosBatchOptions { + seenTitles: ReadonlySet + thumbWidth?: number + batchTitleLimit?: number + signal?: AbortSignal +} + +export interface FetchCommonsPhotosBatchResult { + images: CarouselImage[] + cursor: CommonsPhotosFeedCursor + hasMore: boolean +} + +export async function fetchCommonsPhotosBatch( + source: CommonsPhotosFeedSource, + cursor: CommonsPhotosFeedCursor, + options: FetchCommonsPhotosBatchOptions, +): Promise { + const { + seenTitles, + thumbWidth = GRID_THUMB_WIDTH, + batchTitleLimit = GRID_BATCH_TITLE_LIMIT, + signal, + } = options + + if (!cursorHasPendingTitles(cursor)) { + return { images: [], cursor, hasMore: false } + } + + const { titles, cursor: nextCursor } = await pullCommonsPhotoTitles( + cursor, + seenTitles, + batchTitleLimit, + signal, + ) + + if (titles.length === 0) { + return { + images: [], + cursor: nextCursor, + hasMore: cursorHasPendingTitles(nextCursor), + } + } + + const details = await fetchImageDetails(titles, thumbWidth, signal).catch(() => [] as CommonsImage[]) + + const images = details.map(commonsImageToCarousel) + + return { + images, + cursor: nextCursor, + hasMore: cursorHasPendingTitles(nextCursor), + } +} + +export interface FetchCarouselImagesOptions { + imageFilename: string | null + commonsCategory: string | null + label: string + signal?: AbortSignal +} + +export interface FetchCarouselImagesResult { + images: CarouselImage[] +} + +function selectCarouselImages(candidates: CommonsImage[]): CarouselImage[] { + const images: CarouselImage[] = [] + const seen = new Set() + + for (const candidate of candidates) { + if (images.length >= MAX_CAROUSEL_IMAGES) break + if (candidate.width <= 0 || candidate.height <= 0) continue + if (seen.has(candidate.title)) continue + seen.add(candidate.title) + images.push(commonsImageToCarousel(candidate)) + } + + return images +} + +export async function fetchCarouselImages({ + imageFilename, + commonsCategory, + label, + signal, +}: FetchCarouselImagesOptions): Promise { + const source: CommonsPhotosFeedSource = { imageFilename, commonsCategory, label } + let cursor = createCommonsPhotosFeedCursor(source) + const seen = new Set() + const candidates: CommonsImage[] = [] + + while (candidates.length < CANDIDATE_LIMIT) { + const batch = await fetchCommonsPhotosBatch(source, cursor, { + seenTitles: seen, + thumbWidth: THUMB_WIDTH, + batchTitleLimit: BATCH_TITLE_LIMIT, + signal, + }) + cursor = batch.cursor + + for (const image of batch.images) { + if (!image.title || seen.has(image.title)) continue + seen.add(image.title) + candidates.push({ + title: image.title, + url: image.url, + width: image.width, + height: image.height, + }) + if (candidates.length >= CANDIDATE_LIMIT) break + } + + if (!batch.hasMore) break + } + + return { + images: selectCarouselImages(candidates), + } +} + +/** Build a Commons file page URL from a canonical `File:` title. */ +export function commonsFilePageUrl(title: string): string { + const name = title.replace(/^File:/i, '').trim().replace(/ /g, '_') + return `https://commons.wikimedia.org/wiki/File:${encodeURIComponent(name)}` +} + +/** Stable dedupe key for a carousel/grid image (title, or parsed from Commons URL). */ +export function carouselImageDedupeKey(image: CarouselImage): string { + if (image.title) return normalizeFileTitle(image.title) + + try { + const decoded = decodeURIComponent(image.url) + const thumbMatch = decoded.match(/\/commons\/thumb\/(?:[^/]+\/){2}([^/]+)\/\d+px-/i) + if (thumbMatch) return normalizeFileTitle(`File:${thumbMatch[1]}`) + + const fileMatch = decoded.match(/\/commons\/([a-f0-9]\/[a-f0-9]{2}\/[^/?#]+)/i) + if (fileMatch) { + const filename = fileMatch[1].split('/').pop() ?? fileMatch[1] + return normalizeFileTitle(`File:${filename}`) + } + } catch { + // decodeURIComponent can throw on malformed URLs + } + + return image.url +} + +export { GRID_THUMB_WIDTH, MAX_CAROUSEL_IMAGES, normalizeFileTitle } diff --git a/src/prototypes/musical-group/data/editOpportunityCopy.ts b/src/prototypes/musical-group/data/editOpportunityCopy.ts new file mode 100644 index 0000000..a16be1b --- /dev/null +++ b/src/prototypes/musical-group/data/editOpportunityCopy.ts @@ -0,0 +1,55 @@ +export interface EditOpportunityCopy { + title: string + body: string +} + +const EDIT_OPPORTUNITY_COPY: Record = { + 'Add more references': { + title: 'Find a reference', + body: 'Help readers understand where this information is coming from.', + }, + 'Add more internal wikilinks': { + title: 'Add links', + body: 'Connect this article to related topics on Wikipedia.', + }, + 'Improve article section headings': { + title: 'Improve headings', + body: 'Give the article clearer structure with section headings.', + }, + 'Add images or other media': { + title: 'Add images', + body: 'Illustrate the article with photos or other media.', + }, + 'Add an infobox': { + title: 'Add an infobox', + body: 'Summarize key facts in a structured infobox.', + }, + 'Add more relevant categories': { + title: 'Add categories', + body: 'Help readers discover this article through categories.', + }, + 'Expand the content': { + title: 'Extend article', + body: 'Add more detail so the article better covers its topic.', + }, + 'This article is too short, try to expand the content': { + title: 'Extend article', + body: 'Add more detail so the article better covers its topic.', + }, +} + +/** Needs we skip when surfacing an edit card (maintenance banners are not actionable for readers). */ +const EXCLUDED_EDIT_OPPORTUNITY_NEEDS = new Set([ + 'Check maintenance message', + 'Check article for a maintenance message', +]) + +export function isExcludedEditOpportunityNeed(need: string): boolean { + return EXCLUDED_EDIT_OPPORTUNITY_NEEDS.has(need) +} + +export function resolveEditOpportunityCopy(need: string): EditOpportunityCopy { + const mapped = EDIT_OPPORTUNITY_COPY[need] + if (mapped) return mapped + return { title: need, body: '' } +} diff --git a/src/prototypes/musical-group/data/editOpportunityIcons.ts b/src/prototypes/musical-group/data/editOpportunityIcons.ts new file mode 100644 index 0000000..4b07888 --- /dev/null +++ b/src/prototypes/musical-group/data/editOpportunityIcons.ts @@ -0,0 +1,25 @@ +import type { Icon } from '@wikimedia/codex-icons' +import { + cdxIconEdit, + cdxIconImage, + cdxIconLink, + cdxIconListBullet, + cdxIconReference, + cdxIconTag, + cdxIconTemplateAdd, +} from '@wikimedia/codex-icons' + +const EDIT_OPPORTUNITY_ICONS: Record = { + 'Add more references': cdxIconReference, + 'Add more internal wikilinks': cdxIconLink, + 'Improve article section headings': cdxIconListBullet, + 'Add images or other media': cdxIconImage, + 'Add an infobox': cdxIconTemplateAdd, + 'Add more relevant categories': cdxIconTag, + 'Expand the content': cdxIconEdit, + 'This article is too short, try to expand the content': cdxIconEdit, +} + +export function resolveEditOpportunityIcon(need: string): Icon { + return EDIT_OPPORTUNITY_ICONS[need] ?? cdxIconEdit +} diff --git a/src/prototypes/musical-group/data/editSummaryDisplay.ts b/src/prototypes/musical-group/data/editSummaryDisplay.ts new file mode 100644 index 0000000..f1e21ec --- /dev/null +++ b/src/prototypes/musical-group/data/editSummaryDisplay.ts @@ -0,0 +1,124 @@ +/** Automated revert/undo prefixes from MediaWiki (en). */ +const REVERT_PREFIX = /^(Reverted|Undid|Rollback)/i + +/** User-page boilerplate links flattened to “(talk)” / “(contribs)”. */ +const USER_PAGE_PAREN = /\s*\((?:talk|contribs)\)/gi + +function normalizeWhitespace(text: string): string { + return text.replace(/\s+/g, ' ').trim() +} + +function removeUserPageLinks(doc: Document): void { + for (const anchor of doc.querySelectorAll('a')) { + const label = anchor.textContent?.trim().toLowerCase() + if (label !== 'talk' && label !== 'contribs') continue + + const prev = anchor.previousSibling + const next = anchor.nextSibling + if ( + prev?.nodeType === Node.TEXT_NODE && + next?.nodeType === Node.TEXT_NODE && + /\(\s*$/.test(prev.textContent ?? '') && + /^\s*\)/.test(next.textContent ?? '') + ) { + prev.textContent = (prev.textContent ?? '').replace(/\(\s*$/, '') + next.textContent = (next.textContent ?? '').replace(/^\s*\)/, '') + } + anchor.remove() + } +} + +function flattenParsedComment(html: string): string { + const doc = new DOMParser().parseFromString(html, 'text/html') + removeUserPageLinks(doc) + return normalizeWhitespace( + (doc.body.textContent ?? '') + .replace(/\(\s*\)/g, ''), + ) +} + +function stripUserPageParentheticals(text: string): string { + return text.replace(USER_PAGE_PAREN, '').replace(/\s{2,}/g, ' ').trim() +} + +/** Split MediaWiki autocomment (section link) from the editor’s own summary text. */ +function parseAutocommentFromHtml(html: string): { auto: string; human: string } | null { + const doc = new DOMParser().parseFromString(html, 'text/html') + removeUserPageLinks(doc) + + const autocomment = doc.querySelector('.autocomment') + if (!autocomment) return null + + const auto = normalizeWhitespace(autocomment.textContent ?? '') + autocomment.remove() + const human = normalizeWhitespace(doc.body.textContent ?? '') + + return { auto, human } +} + +/** Fallback when parsed HTML is unavailable: section autocomment plus message in raw text. */ +function parseSectionFromRaw(raw: string): { auto: string; human: string } | null { + const match = raw.trim().match(/^\/\*\s*(.*?)\s*\*\/\s*(.*)$/s) + if (!match) return null + + const sectionName = match[1].trim() + const human = match[2].trim() + const auto = sectionName ? `→${sectionName}` : '→(top)' + + return { auto, human } +} + +function formatSectionAndMessage(auto: string, human: string): string { + const section = auto.replace(/:?\s*$/, '') + if (section && human) return `${section}: “${human}”` + if (section) return section + if (human) return `“${human}”` + return 'No edit summary' +} + +function formatRevertSummary(text: string): string | null { + if (!REVERT_PREFIX.test(text)) return null + + const colonIdx = text.indexOf(': ') + if (colonIdx === -1) return text + + const prefix = text.slice(0, colonIdx).trim() + const reason = text.slice(colonIdx + 2).trim() + if (reason) return `${prefix}: “${reason}”` + return prefix +} + +/** + * Readable edit summary for card copy. Section links (`→Members`) are shown + * without quotes; only the editor’s own text is quoted. Revert/undo summaries + * quote the reason after the colon. + */ +export function formatEditSummaryDisplay(parsedComment: string, rawComment: string): string { + const html = parsedComment.trim() + const raw = rawComment.trim() + + if (html) { + const autocomment = parseAutocommentFromHtml(html) + if (autocomment) { + const human = stripUserPageParentheticals(autocomment.human) + const formatted = formatSectionAndMessage(autocomment.auto, human) + if (formatted !== 'No edit summary') return formatted + } + } + + let text = html ? flattenParsedComment(html) : '' + if (!text) text = raw + text = stripUserPageParentheticals(text) + if (!text) return 'No edit summary' + + const revert = formatRevertSummary(text) + if (revert) return revert + + const rawSection = parseSectionFromRaw(raw) + if (rawSection) { + const human = stripUserPageParentheticals(rawSection.human) + return formatSectionAndMessage(rawSection.auto, human) + } + + return `“${text}”` +} diff --git a/src/prototypes/musical-group/data/editThanks.ts b/src/prototypes/musical-group/data/editThanks.ts new file mode 100644 index 0000000..ba62aa2 --- /dev/null +++ b/src/prototypes/musical-group/data/editThanks.ts @@ -0,0 +1,47 @@ +const STORAGE_KEY = 'musical-group-edit-thanks' + +function readRevids(): Set { + if (typeof window === 'undefined') return new Set() + try { + const raw = window.localStorage.getItem(STORAGE_KEY) + if (!raw) return new Set() + const parsed = JSON.parse(raw) as unknown + if (!Array.isArray(parsed)) return new Set() + + const revids = new Set() + for (const item of parsed) { + if (typeof item === 'number' && Number.isFinite(item) && item > 0) { + revids.add(item) + } + } + return revids + } catch { + return new Set() + } +} + +function writeRevids(revids: Set): void { + if (typeof window === 'undefined') return + try { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify([...revids])) + } catch { + // Ignore quota / private mode failures. + } +} + +export function isEditThanked(revid: number): boolean { + return readRevids().has(revid) +} + +/** Toggle thank for this revision; returns the new thanked state. */ +export function toggleEditThank(revid: number): boolean { + const revids = readRevids() + if (revids.has(revid)) { + revids.delete(revid) + writeRevids(revids) + return false + } + revids.add(revid) + writeRevids(revids) + return true +} diff --git a/src/prototypes/musical-group/data/enwikiTitle.ts b/src/prototypes/musical-group/data/enwikiTitle.ts new file mode 100644 index 0000000..77cbefd --- /dev/null +++ b/src/prototypes/musical-group/data/enwikiTitle.ts @@ -0,0 +1,185 @@ +import { wikimediaApiFetchHeaders } from '@/config' + +import { fetchWikimedia } from '@/lib/fetchWikimedia' +import { normalizeQid } from './wikidataApi' + +export const EN_WIKI_HOST = 'en.wikipedia.org' + +const EN_WIKI_PREFIXES = ['File:', 'Category:', 'Help:', 'Wikipedia:', 'Template:', 'Portal:'] + +export function wikiActionUrl(params: Record): string { + const search = new URLSearchParams({ + ...params, + format: 'json', + origin: '*', + }) + return `https://${EN_WIKI_HOST}/w/api.php?${search.toString()}` +} + +export function normalizeEnwikiTitle(title: string): string { + return title.trim().replace(/_/g, ' ').replace(/\s+/g, ' ') +} + +export function enwikiArticleUrl(title: string): string { + const slug = encodeURIComponent(title.replace(/ /g, '_')) + return `https://${EN_WIKI_HOST}/wiki/${slug}` +} + +export function resolveExternalUrl(href: string): string { + if (href.startsWith('//')) return `https:${href}` + return href +} + +/** True for off-wiki http(s) and protocol-relative URLs. */ +export function isExternalHref(href: string): boolean { + if (!href || href.startsWith('#') || href.startsWith('./') || href.startsWith('/wiki/')) { + return false + } + + if (href.startsWith('//')) return true + + if (!/^https?:\/\//i.test(href)) return false + + try { + const host = new URL(href).hostname + return host !== EN_WIKI_HOST && host !== `www.${EN_WIKI_HOST}` + } catch { + return true + } +} + +export function enwikiTitlesMatch(a: string, b: string): boolean { + return normalizeEnwikiTitle(a).toLowerCase() === normalizeEnwikiTitle(b).toLowerCase() +} + +export function parseEnwikiArticleLink(href: string): { + title: string | null + fragment: string | null +} { + if (!href) return { title: null, fragment: null } + + if (href.startsWith('#')) { + return { + title: null, + fragment: decodeURIComponent(href.slice(1)), + } + } + + const hashIndex = href.indexOf('#') + const fragment = + hashIndex >= 0 ? decodeURIComponent(href.slice(hashIndex + 1)) : null + const path = hashIndex >= 0 ? href.slice(0, hashIndex) : href + + return { + title: parseEnwikiArticleTitle(path), + fragment, + } +} + +/** + * Parse an enwiki article title from a Parsoid / wiki anchor href, or null if not + * a main-namespace article link. + */ +export function parseEnwikiArticleTitle(href: string): string | null { + if (!href || href.startsWith('#')) return null + + let path = href + if (/^https?:\/\//i.test(href)) { + try { + const url = new URL(href) + if (url.hostname !== EN_WIKI_HOST && url.hostname !== `www.${EN_WIKI_HOST}`) return null + path = url.pathname + } catch { + return null + } + } + + let titlePart = '' + if (path.startsWith('./')) { + titlePart = decodeURIComponent(path.slice(2)) + } else { + const wikiMatch = path.match(/\/wiki\/(.+)$/) + if (!wikiMatch) return null + titlePart = decodeURIComponent(wikiMatch[1]) + } + + titlePart = titlePart.replace(/_/g, ' ') + const hashIndex = titlePart.indexOf('#') + if (hashIndex >= 0) titlePart = titlePart.slice(0, hashIndex) + + const normalized = normalizeEnwikiTitle(titlePart) + if (!normalized) return null + + for (const prefix of EN_WIKI_PREFIXES) { + if (normalized.startsWith(prefix)) return null + } + + return normalized +} + +export async function fetchWikibaseItemId( + title: string, + signal?: AbortSignal, +): Promise { + const url = wikiActionUrl({ + action: 'query', + prop: 'pageprops', + ppprop: 'wikibase_item', + titles: title, + }) + + const response = await fetchWikimedia(url, { + signal, + headers: wikimediaApiFetchHeaders('musical-group-wikibase-item'), + }) + if (!response.ok) return undefined + + const json = (await response.json()) as { + query?: { pages?: Record } + } + const page = Object.values(json.query?.pages ?? {})[0] + return normalizeQid(page?.pageprops?.wikibase_item) ?? undefined +} + +export async function fetchWikibaseItemIds( + titles: string[], + signal?: AbortSignal, +): Promise> { + const result = new Map() + const unique = [...new Set(titles.map(normalizeEnwikiTitle).filter(Boolean))] + if (!unique.length) return result + + const url = wikiActionUrl({ + action: 'query', + prop: 'pageprops', + ppprop: 'wikibase_item', + titles: unique.join('|'), + }) + + const response = await fetchWikimedia(url, { + signal, + headers: wikimediaApiFetchHeaders('musical-group-wikibase-item-batch'), + }) + if (!response.ok) { + for (const title of unique) result.set(title, undefined) + return result + } + + const json = (await response.json()) as { + query?: { + pages?: Record + } + } + + for (const page of Object.values(json.query?.pages ?? {})) { + const pageTitle = page.title ? normalizeEnwikiTitle(page.title) : undefined + if (!pageTitle) continue + result.set(pageTitle, normalizeQid(page.pageprops?.wikibase_item) ?? undefined) + } + + for (const title of unique) { + if (!result.has(title)) result.set(title, undefined) + } + + return result +} diff --git a/src/prototypes/musical-group/data/fetchEditSuggestion.ts b/src/prototypes/musical-group/data/fetchEditSuggestion.ts new file mode 100644 index 0000000..8c46534 --- /dev/null +++ b/src/prototypes/musical-group/data/fetchEditSuggestion.ts @@ -0,0 +1,135 @@ +import { + loadConfig, + PROTOWIKI_API_PROJECT_URL, + PROTOWIKI_API_USER_AGENT, +} from '@/config' +import { mapWithConcurrency } from '@/lib/mapWithConcurrency' + +import { normalizeEnwikiTitle } from './enwikiTitle' +import { isExcludedEditOpportunityNeed, resolveEditOpportunityCopy } from './editOpportunityCopy' +import { fetchWithTimeout } from './fetchWithTimeout' +import type { HomeHelpWanted, HomeSavedItem } from './types' + +const MICROTASK_QUALITY_CHECK_URL = 'https://microtask-generator.toolforge.org/quality-check' +const SAVED_SUGGESTION_CONCURRENCY = 3 + +interface QualityCheckPotentialNeed { + need?: string + score?: number +} + +interface QualityCheckResult { + title?: string + exists?: boolean + potential_needs?: QualityCheckPotentialNeed[] +} + +export interface EditSuggestionPage { + itemId?: string + title: string + enwikiTitle: string + thumbnailUrl?: string +} + +function stableSuggestionItemId(itemId: string | undefined, enwikiTitle: string): string { + if (itemId) return itemId + return `enwiki:${normalizeEnwikiTitle(enwikiTitle).toLowerCase()}` +} + +function microtaskFetchHeaders(userAgentSuffix: string): HeadersInit { + const contact = loadConfig().apiContact.trim() || 'contact unavailable' + const userAgent = `${PROTOWIKI_API_USER_AGENT} (${PROTOWIKI_API_PROJECT_URL}; ${contact}) ${userAgentSuffix}` + return { + 'Content-Type': 'application/json', + 'User-Agent': userAgent, + } +} + +export async function fetchEditSuggestionForPage( + page: EditSuggestionPage, + relatedToTitle: string, + signal?: AbortSignal, + userAgentSuffix = 'musical-group-edit-suggestion', +): Promise { + try { + const response = await fetchWithTimeout(MICROTASK_QUALITY_CHECK_URL, { + method: 'POST', + signal, + headers: microtaskFetchHeaders(userAgentSuffix), + body: JSON.stringify({ lang: 'en', titles: [page.enwikiTitle] }), + }) + if (!response.ok) return null + + const json = (await response.json()) as { results?: QualityCheckResult[] } + const result = json.results?.[0] + if (!result?.exists) return null + + const needs = (result.potential_needs ?? []) + .filter((entry): entry is { need: string; score: number } => { + return typeof entry.need === 'string' && typeof entry.score === 'number' + }) + .sort((a, b) => b.score - a.score) + + const top = needs.find((entry) => !isExcludedEditOpportunityNeed(entry.need)) + if (!top) return null + + const copy = resolveEditOpportunityCopy(top.need) + return { + itemId: stableSuggestionItemId(page.itemId, page.enwikiTitle), + suggestionLabel: copy.title, + title: page.title, + body: copy.body, + need: top.need, + enwikiTitle: page.enwikiTitle, + thumbnailUrl: page.thumbnailUrl, + relatedToTitle, + } + } catch (err) { + if ((err as Error).name === 'AbortError') throw err + return null + } +} + +export async function fetchEditSuggestionForSavedItem( + item: HomeSavedItem, + signal?: AbortSignal, + userAgentSuffix = 'musical-group-edit-suggestion', +): Promise { + if (!item.enwikiTitle) return null + + return fetchEditSuggestionForPage( + { + itemId: item.id, + title: item.title, + enwikiTitle: item.enwikiTitle, + thumbnailUrl: item.thumbnailUrl, + }, + item.title, + signal, + userAgentSuffix, + ) +} + +/** Quality-check every saved page that has an enwiki article. */ +export async function fetchAllSavedSuggestions( + items: HomeSavedItem[], + signal?: AbortSignal, + options?: { onEach?: (suggestion: HomeHelpWanted) => void }, +): Promise { + const candidates = items.filter((item) => item.enwikiTitle) + const results = await mapWithConcurrency( + candidates, + SAVED_SUGGESTION_CONCURRENCY, + async (item) => { + const suggestion = await fetchEditSuggestionForSavedItem( + item, + signal, + 'musical-group-contribute-saved', + ) + if (suggestion) options?.onEach?.(suggestion) + return suggestion + }, + signal, + ) + return results.filter((entry): entry is HomeHelpWanted => entry !== null) +} diff --git a/src/prototypes/musical-group/data/fetchEntityExternalLinks.ts b/src/prototypes/musical-group/data/fetchEntityExternalLinks.ts new file mode 100644 index 0000000..3e82aa9 --- /dev/null +++ b/src/prototypes/musical-group/data/fetchEntityExternalLinks.ts @@ -0,0 +1,282 @@ +import { wikimediaApiFetchHeaders } from '@/config' + +import { fetchWikimedia } from '@/lib/fetchWikimedia' +import { normalizeUrlForDedup, OFFICIAL_WEBSITE_LABEL } from './mergeExternalLinks' +import { + getSocialPlatformLabel, + isSocialPlatformUrl, +} from './socialPlatforms' +import type { ExternalLinkCategory, WikidataExternalLink } from './types' +import { externalLinkLabel } from './wikidataApi' + +const WIKIDATA_API = 'https://www.wikidata.org/w/api.php' + +interface WbClaim { + rank?: string + mainsnak: { + snaktype?: string + datatype?: string + datavalue?: { + type: string + value: string | { id?: string; time?: string; text?: string } + } + } +} + +interface WbEntity { + id?: string + claims?: Record +} + +interface WbGetEntitiesResponse { + entities?: Record +} + +interface RawEntityLink { + propertyId: string + propertyNumber: number + claimIndex: number + rank: string + url: string +} + +const formatterCache = new Map() + +function actionUrl(params: Record): string { + const search = new URLSearchParams({ + format: 'json', + formatversion: '2', + origin: '*', + ...params, + }) + return `${WIKIDATA_API}?${search.toString()}` +} + +function propertyNumber(propertyId: string): number { + const match = propertyId.match(/^P(\d+)$/i) + return match ? Number.parseInt(match[1], 10) : Number.MAX_SAFE_INTEGER +} + +function claimStringValue(claim: WbClaim): string | null { + if (claim.mainsnak.snaktype === 'novalue' || claim.mainsnak.snaktype === 'somevalue') { + return null + } + + const value = claim.mainsnak.datavalue?.value + if (typeof value === 'string') return value + if (typeof value === 'object' && value && 'text' in value && value.text) { + return value.text + } + return null +} + +function formatterFromPropertyEntity(entity: WbEntity | undefined): string | undefined { + const claims = entity?.claims?.P1630 + if (!claims?.length) return undefined + + const preferred = claims.find((claim) => claim.rank === 'preferred') + const candidate = preferred ?? claims.find((claim) => claim.rank !== 'deprecated') ?? claims[0] + return claimStringValue(candidate) ?? undefined +} + +async function fetchPropertyFormatters( + propertyIds: string[], + signal?: AbortSignal, +): Promise> { + const result = new Map() + const unresolved = propertyIds.filter((id) => !formatterCache.has(id)) + + if (unresolved.length) { + const url = actionUrl({ + action: 'wbgetentities', + ids: unresolved.join('|'), + props: 'claims', + }) + + const response = await fetchWikimedia(url, { + signal, + headers: wikimediaApiFetchHeaders('musical-group-wikidata-formatters'), + }) + + if (response.ok) { + const data = (await response.json()) as WbGetEntitiesResponse + for (const propertyId of unresolved) { + const formatter = formatterFromPropertyEntity(data.entities?.[propertyId]) + formatterCache.set(propertyId, formatter) + } + } else { + for (const propertyId of unresolved) { + formatterCache.set(propertyId, undefined) + } + } + } + + for (const propertyId of propertyIds) { + result.set(propertyId, formatterCache.get(propertyId)) + } + + return result +} + +function buildFormatterUrl(template: string, identifier: string): string { + return template.replace(/\$1/g, identifier) +} + +function rankOrder(rank: string | undefined): number { + if (rank === 'preferred') return 0 + if (rank === 'normal') return 1 + return 2 +} + +/** Official website first, then social/streaming, then others; alphabetically within each tier. */ +function compareEntityLinks(a: RawEntityLink, b: RawEntityLink): number { + const tier = (link: RawEntityLink): number => { + if (link.propertyId === 'P856') return 0 + if (isSocialPlatformUrl(link.url, link.propertyId)) return 1 + return 2 + } + + const tierDiff = tier(a) - tier(b) + if (tierDiff !== 0) return tierDiff + + const labelDiff = (() => { + const aLabel = + getSocialPlatformLabel(a.url, a.propertyId) ?? + (a.propertyId === 'P856' ? OFFICIAL_WEBSITE_LABEL : externalLinkLabel(a.url)) + const bLabel = + getSocialPlatformLabel(b.url, b.propertyId) ?? + (b.propertyId === 'P856' ? OFFICIAL_WEBSITE_LABEL : externalLinkLabel(b.url)) + return aLabel.localeCompare(bLabel, undefined, { sensitivity: 'base' }) + })() + if (labelDiff !== 0) return labelDiff + + const rankDiff = rankOrder(a.rank) - rankOrder(b.rank) + if (rankDiff !== 0) return rankDiff + + return a.claimIndex - b.claimIndex +} + +function linkCategory(propertyId: string, url: string): ExternalLinkCategory { + if (propertyId === 'P856') return 'official' + if (isSocialPlatformUrl(url, propertyId)) return 'social' + return 'other' +} + +function toExternalLink(link: RawEntityLink): WikidataExternalLink { + const category = linkCategory(link.propertyId, link.url) + const socialLabel = getSocialPlatformLabel(link.url, link.propertyId) + + let displayText: string + if (socialLabel) { + displayText = socialLabel + } else if (category === 'official') { + displayText = OFFICIAL_WEBSITE_LABEL + } else { + displayText = externalLinkLabel(link.url) + } + + return { + url: link.url, + displayText, + category, + } +} + +export async function fetchEntityExternalLinks( + id: string, + signal?: AbortSignal, +): Promise { + const url = actionUrl({ + action: 'wbgetentities', + ids: id, + props: 'claims', + }) + + const response = await fetchWikimedia(url, { + signal, + headers: wikimediaApiFetchHeaders('musical-group-wikidata-links'), + }) + + if (!response.ok) { + throw new Error(`wbgetentities failed (${response.status})`) + } + + const data = (await response.json()) as WbGetEntitiesResponse + const entity = data.entities?.[id] + if (!entity?.claims) return [] + + const urlClaims: RawEntityLink[] = [] + const externalIdClaims: { propertyId: string; propertyNumber: number; claimIndex: number; rank: string; value: string }[] = [] + + for (const [propertyId, claims] of Object.entries(entity.claims)) { + const propNumber = propertyNumber(propertyId) + + claims.forEach((claim, claimIndex) => { + if (claim.rank === 'deprecated') return + + const datatype = claim.mainsnak.datatype + const value = claimStringValue(claim) + if (!value) return + + const rank = claim.rank ?? 'normal' + + if (datatype === 'url') { + urlClaims.push({ + propertyId, + propertyNumber: propNumber, + claimIndex, + rank, + url: value, + }) + return + } + + if (datatype === 'external-id') { + externalIdClaims.push({ + propertyId, + propertyNumber: propNumber, + claimIndex, + rank, + value, + }) + } + }) + } + + const propertyIds = [...new Set(externalIdClaims.map((claim) => claim.propertyId))] + const formatters = propertyIds.length + ? await fetchPropertyFormatters(propertyIds, signal) + : new Map() + + const resolvedLinks: RawEntityLink[] = [...urlClaims] + + for (const claim of externalIdClaims) { + const template = formatters.get(claim.propertyId) + if (!template) continue + resolvedLinks.push({ + propertyId: claim.propertyId, + propertyNumber: claim.propertyNumber, + claimIndex: claim.claimIndex, + rank: claim.rank, + url: buildFormatterUrl(template, claim.value), + }) + } + + resolvedLinks.sort(compareEntityLinks) + + const seen = new Set() + const links: WikidataExternalLink[] = [] + + for (const rawLink of resolvedLinks) { + const trimmed = rawLink.url.trim() + if (!trimmed) continue + + const key = normalizeUrlForDedup(trimmed) + if (seen.has(key)) continue + seen.add(key) + + links.push(toExternalLink(rawLink)) + } + + return links +} diff --git a/src/prototypes/musical-group/data/fetchEnwikiFeaturedFeedDay.ts b/src/prototypes/musical-group/data/fetchEnwikiFeaturedFeedDay.ts new file mode 100644 index 0000000..1875a6f --- /dev/null +++ b/src/prototypes/musical-group/data/fetchEnwikiFeaturedFeedDay.ts @@ -0,0 +1,98 @@ +import { wikimediaApiFetchHeaders } from '@/config' +import { fetchWikimedia } from '@/lib/fetchWikimedia' + +import { utcDayParts } from './cacheKeys' +import { EN_WIKI_HOST } from './enwikiTitle' + +export interface FeaturedFeedDayResponse { + tfa?: { + title?: string + normalizedtitle?: string + description?: string + extract?: string + thumbnail?: { source?: string } + content_urls?: { desktop?: { page?: string } } + wikibase_item?: string + } + dyk?: { + text?: string + html?: string + pages?: { title?: string }[] + }[] + mostread?: { + date?: string + articles?: { + title?: string + views?: number + rank?: number + }[] + } +} + +const sessionCache = new Map() +const inFlight = new Map>() + +export interface FeaturedFeedDayResult { + dayKey: string + json: FeaturedFeedDayResponse | null + ok: boolean + status?: number +} + +function featuredFeedUrl(date = new Date()): { url: string; dayKey: string } { + const { yyyy, mm, dd, key } = utcDayParts(date) + return { + dayKey: key, + url: `https://${EN_WIKI_HOST}/api/rest_v1/feed/featured/${yyyy}/${mm}/${dd}`, + } +} + +/** Shared daily featured feed fetch with session dedup. */ +export async function fetchEnwikiFeaturedFeedDay( + signal?: AbortSignal, + purpose = 'musical-group-featured-feed', +): Promise { + const { url, dayKey } = featuredFeedUrl() + + const sessionHit = sessionCache.get(dayKey) + if (sessionHit) return { dayKey, json: sessionHit, ok: true } + + let bodyPromise = inFlight.get(dayKey) + if (!bodyPromise) { + bodyPromise = (async (): Promise => { + const response = await fetchWikimedia(url, { + signal, + headers: wikimediaApiFetchHeaders(purpose), + }) + if (!response.ok) { + return { dayKey, json: null, ok: false, status: response.status } + } + const json = (await response.json()) as FeaturedFeedDayResponse + sessionCache.set(dayKey, json) + return { dayKey, json, ok: true } + })().finally(() => { + inFlight.delete(dayKey) + }) + inFlight.set(dayKey, bodyPromise) + } + + return bodyPromise +} + +export function wikimediaFeedErrorMessage( + status: number | undefined, + resource: string, +): string { + if (status === 429) { + return `${resource} is temporarily unavailable. Wikipedia may be rate-limiting requests — try again shortly.` + } + if (status) { + return `${resource} could not be loaded (HTTP ${status}).` + } + return `${resource} could not be loaded. Check your connection and try again.` +} + +export function clearFeaturedFeedSessionCache(): void { + sessionCache.clear() + inFlight.clear() +} diff --git a/src/prototypes/musical-group/data/fetchFeaturedArticle.ts b/src/prototypes/musical-group/data/fetchFeaturedArticle.ts new file mode 100644 index 0000000..666c684 --- /dev/null +++ b/src/prototypes/musical-group/data/fetchFeaturedArticle.ts @@ -0,0 +1 @@ +export { fetchFeaturedArticle, fetchFeaturedTabContent } from './fetchFeaturedFeed' diff --git a/src/prototypes/musical-group/data/fetchFeaturedFeed.ts b/src/prototypes/musical-group/data/fetchFeaturedFeed.ts new file mode 100644 index 0000000..61f1632 --- /dev/null +++ b/src/prototypes/musical-group/data/fetchFeaturedFeed.ts @@ -0,0 +1,281 @@ +import { wikimediaApiFetchHeaders } from '@/config' +import { fetchWikimedia } from '@/lib/fetchWikimedia' +import { mapWithConcurrency } from '@/lib/mapWithConcurrency' + +import { utcDayKey, utcDayParts } from './cacheKeys' +import { EN_WIKI_HOST, enwikiArticleUrl } from './enwikiTitle' +import { fetchEnwikiFeaturedFeedDay, wikimediaFeedErrorMessage } from './fetchEnwikiFeaturedFeedDay' +import { + getCachedFeaturedTab, + setCachedFeaturedTab, +} from './homeTabCache' +import { fetchPageSummary } from './pageSummary' +import type { HomeBornOnThisDay, HomeDidYouKnow, HomeFeatured, HomeFeaturedTab } from './types' +import { normalizeQid } from './wikidataApi' + +const MAX_DYK = 5 +const MAX_BIRTHS = 5 +const SUMMARY_CONCURRENCY = 3 + +interface FeedPage { + title?: string +} + +interface FeedTfa { + title?: string + normalizedtitle?: string + description?: string + extract?: string + thumbnail?: { source?: string } + content_urls?: { desktop?: { page?: string } } + wikibase_item?: string +} + +interface FeedDyk { + text?: string + html?: string + pages?: FeedPage[] +} + +interface FeedBirth { + text?: string + year?: number + pages?: FeedPage[] +} + +interface FeaturedFeedResponse { + tfa?: FeedTfa + dyk?: FeedDyk[] +} + +interface BirthsFeedResponse { + births?: FeedBirth[] +} + +let sessionCached: { day: string; value: HomeFeaturedTab } | null = null + +function parseTfa(tfa: FeedTfa | undefined): HomeFeatured | undefined { + if (!tfa?.title) return undefined + + const enwikiTitle = (tfa.normalizedtitle ?? tfa.title).replace(/_/g, ' ') + return { + title: enwikiTitle, + enwikiTitle, + description: tfa.description ?? tfa.extract ?? '', + thumbnailUrl: tfa.thumbnail?.source, + articleUrl: tfa.content_urls?.desktop?.page ?? enwikiArticleUrl(enwikiTitle), + itemId: normalizeQid(tfa.wikibase_item) ?? undefined, + } +} + +async function pageCardFields( + enwikiTitle: string, + signal?: AbortSignal, +): Promise<{ + title: string + thumbnailUrl?: string + articleUrl: string + itemId?: string +}> { + const summary = await fetchPageSummary(enwikiTitle, signal, 'musical-group-featured-feed') + const title = (summary?.normalizedtitle ?? summary?.title ?? enwikiTitle).replace(/_/g, ' ') + return { + title, + thumbnailUrl: summary?.thumbnail?.source, + articleUrl: summary?.content_urls?.desktop?.page ?? enwikiArticleUrl(enwikiTitle), + itemId: normalizeQid(summary?.wikibase_item) ?? undefined, + } +} + +function extractDykEmphasis(html: string): string | undefined { + const match = html.match(/]*>\s*]*>([\s\S]*?)<\/a>/i) + if (!match) return undefined + + return match[1] + .replace(/<[^>]+>/g, '') + .replace(/ /gi, ' ') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/\u00a0/g, ' ') + .replace(/\s+/g, ' ') + .trim() +} + +function splitTextEmphasis( + text: string, + emphasis: string, +): { before: string; match: string; after: string } | null { + const pattern = emphasis + .replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + .replace(/\s+/g, '[\\u00a0 ]+') + const match = text.match(new RegExp(pattern)) + if (!match?.index && match?.index !== 0) return null + + return { + before: text.slice(0, match.index), + match: match[0], + after: text.slice(match.index + match[0].length), + } +} + +function resolveDykEmphasis(text: string, html?: string): string | undefined { + if (!html) return undefined + const emphasis = extractDykEmphasis(html) + if (!emphasis || !splitTextEmphasis(text, emphasis)) return undefined + return emphasis +} + +function dykPrimaryPageTitle(item: FeedDyk): string | undefined { + const fromPages = item.pages?.[0]?.title + if (fromPages) return fromPages + + const html = item.html + if (!html) return undefined + + const match = html.match(/href="(?:https:\/\/en\.wikipedia\.org\/wiki\/|\.\/)([^"?#]+)"/i) + if (!match) return undefined + + try { + return decodeURIComponent(match[1]) + } catch { + return match[1] + } +} + +async function parseDidYouKnow( + items: FeedDyk[] | undefined, + signal?: AbortSignal, +): Promise { + if (!items?.length) return [] + + const slice = items.slice(0, MAX_DYK) + + const results = await mapWithConcurrency( + slice, + SUMMARY_CONCURRENCY, + async (item) => { + const text = item.text?.trim() + if (!text) return null + + const emphasis = resolveDykEmphasis(text, item.html) + const pageTitle = dykPrimaryPageTitle(item) + if (!pageTitle) { + return { + text, + ...(emphasis ? { emphasis } : {}), + } satisfies HomeDidYouKnow + } + + const fields = await pageCardFields(pageTitle, signal) + return { + text, + ...(emphasis ? { emphasis } : {}), + enwikiTitle: pageTitle.replace(/_/g, ' '), + title: fields.title, + thumbnailUrl: fields.thumbnailUrl, + articleUrl: fields.articleUrl, + itemId: fields.itemId, + } satisfies HomeDidYouKnow + }, + signal, + ) + + return results.filter((entry): entry is HomeDidYouKnow => entry !== null) +} + +async function parseBornOnThisDay( + items: FeedBirth[] | undefined, + signal?: AbortSignal, +): Promise { + if (!items?.length) return [] + + const slice = items + .slice(0, MAX_BIRTHS) + .filter((item) => item.pages?.[0]?.title && item.text?.trim() && item.year != null) + + return mapWithConcurrency( + slice, + SUMMARY_CONCURRENCY, + async (item) => { + const pageTitle = item.pages![0].title! + const fields = await pageCardFields(pageTitle, signal) + return { + year: item.year!, + text: item.text!.trim(), + title: fields.title, + enwikiTitle: pageTitle.replace(/_/g, ' '), + thumbnailUrl: fields.thumbnailUrl, + articleUrl: fields.articleUrl, + itemId: fields.itemId, + } satisfies HomeBornOnThisDay + }, + signal, + ) +} + +export function isUsableFeaturedTab(tab: HomeFeaturedTab): boolean { + return Boolean(tab.article) || tab.didYouKnow.length > 0 || tab.bornOnThisDay.length > 0 +} + +export function clearFeaturedTabSessionCache(): void { + sessionCached = null +} + +/** Today's featured tab: article of the day, Did you know, and Born on this day. */ +export async function fetchFeaturedTabContent(signal?: AbortSignal): Promise { + const dayKey = utcDayKey() + + const stored = getCachedFeaturedTab(dayKey) + if (stored && isUsableFeaturedTab(stored)) { + sessionCached = { day: dayKey, value: stored } + return stored + } + + if (sessionCached && sessionCached.day === dayKey && isUsableFeaturedTab(sessionCached.value)) { + return sessionCached.value + } + + const { mm, dd } = utcDayParts() + const birthsUrl = `https://${EN_WIKI_HOST}/api/rest_v1/feed/onthisday/births/${mm}/${dd}` + + const [{ ok: featuredOk, json: featuredJson, status: featuredStatus }, birthsResponse] = + await Promise.all([ + fetchEnwikiFeaturedFeedDay(signal, 'musical-group-featured-feed'), + fetchWikimedia(birthsUrl, { + signal, + headers: wikimediaApiFetchHeaders('musical-group-born-on-this-day'), + }), + ]) + + if (!featuredOk) { + throw new Error(wikimediaFeedErrorMessage(featuredStatus, 'Featured content')) + } + + const featured = (featuredJson ?? {}) as FeaturedFeedResponse + const birthsJson = birthsResponse.ok + ? ((await birthsResponse.json()) as BirthsFeedResponse) + : {} + + const [didYouKnow, bornOnThisDay] = await Promise.all([ + parseDidYouKnow(featured.dyk, signal), + parseBornOnThisDay(birthsJson.births, signal), + ]) + + const value: HomeFeaturedTab = { + article: parseTfa(featured.tfa), + didYouKnow, + bornOnThisDay, + } + + sessionCached = { day: dayKey, value } + if (isUsableFeaturedTab(value)) { + setCachedFeaturedTab(dayKey, value) + } + return value +} + +/** Today's featured article only — convenience wrapper. */ +export async function fetchFeaturedArticle(signal?: AbortSignal): Promise { + return (await fetchFeaturedTabContent(signal)).article +} diff --git a/src/prototypes/musical-group/data/fetchHelpWanted.ts b/src/prototypes/musical-group/data/fetchHelpWanted.ts new file mode 100644 index 0000000..ab132fe --- /dev/null +++ b/src/prototypes/musical-group/data/fetchHelpWanted.ts @@ -0,0 +1,104 @@ +import { bookmarksKey } from './cacheKeys' +import { normalizeEnwikiTitle } from './enwikiTitle' +import { + fetchEditSuggestionForPage, + fetchEditSuggestionForSavedItem, +} from './fetchEditSuggestion' +import { fetchMorelikeTitles, resolveRelatedSummary } from './fetchRelatedReading' +import { getCachedHelpWanted, setCachedHelpWanted } from './homeTabCache' +import type { HomeHelpWanted, HomeSavedItem } from './types' + +const HOME_HELP_WANTED_LIMIT = 2 + +async function fetchUnsavedSuggestion( + items: HomeSavedItem[], + signal?: AbortSignal, + existing: HomeHelpWanted[] = [], +): Promise { + const seeds = items.filter((item) => item.enwikiTitle) + if (!seeds.length) return null + + const excludedTitles = new Set() + const excludedIds = new Set() + for (const item of items) { + excludedIds.add(item.id) + if (item.enwikiTitle) { + excludedTitles.add(normalizeEnwikiTitle(item.enwikiTitle).toLowerCase()) + } + } + for (const suggestion of existing) { + excludedIds.add(suggestion.itemId) + if (suggestion.enwikiTitle) { + excludedTitles.add(normalizeEnwikiTitle(suggestion.enwikiTitle).toLowerCase()) + } + } + + const shuffledSeeds = [...seeds].sort(() => Math.random() - 0.5) + + for (const seed of shuffledSeeds) { + const titles = await fetchMorelikeTitles(seed.enwikiTitle as string, signal, 8) + + for (const title of titles) { + const titleKey = normalizeEnwikiTitle(title).toLowerCase() + if (excludedTitles.has(titleKey)) continue + + const summary = await resolveRelatedSummary(title, seed.title, signal) + if (!summary?.itemId) continue + if (excludedIds.has(summary.itemId)) continue + + excludedTitles.add(titleKey) + excludedIds.add(summary.itemId) + + const suggestion = await fetchEditSuggestionForPage( + { + itemId: summary.itemId, + title: summary.title, + enwikiTitle: title, + thumbnailUrl: summary.thumbnailUrl, + }, + seed.title, + signal, + 'musical-group-help-wanted', + ) + if (suggestion) return suggestion + } + } + + return null +} + +/** Up to two edit suggestions for the home Help wanted preview. */ +export async function fetchHelpWanted( + items: HomeSavedItem[], + signal?: AbortSignal, +): Promise { + const dependencyKey = bookmarksKey() + const cached = getCachedHelpWanted(dependencyKey) + if (cached?.length >= HOME_HELP_WANTED_LIMIT) { + return cached.slice(0, HOME_HELP_WANTED_LIMIT) + } + + const suggestions: HomeHelpWanted[] = [] + const savedCandidates = items.filter((item) => item.enwikiTitle) + + for (const item of savedCandidates) { + if (suggestions.length >= HOME_HELP_WANTED_LIMIT) break + const suggestion = await fetchEditSuggestionForSavedItem( + item, + signal, + 'musical-group-help-wanted', + ) + if (suggestion) suggestions.push(suggestion) + } + + while (suggestions.length < HOME_HELP_WANTED_LIMIT) { + const unsavedSuggestion = await fetchUnsavedSuggestion(items, signal, suggestions) + if (!unsavedSuggestion) break + suggestions.push(unsavedSuggestion) + } + + if (suggestions.length) { + setCachedHelpWanted(dependencyKey, suggestions) + } + return suggestions +} diff --git a/src/prototypes/musical-group/data/fetchMusicalGroup.ts b/src/prototypes/musical-group/data/fetchMusicalGroup.ts new file mode 100644 index 0000000..72e856c --- /dev/null +++ b/src/prototypes/musical-group/data/fetchMusicalGroup.ts @@ -0,0 +1,278 @@ +import { + commonsImageCountFromCategory, + fetchCarouselImages, + getCommonsCategoryCount, + resolveCommonsCategory, +} from './commonsImages' +import { sentenceCase } from './formatLabel' +import type { + CarouselImage, + EditIndicator, + FetchMusicalGroupOptions, + FetchMusicalGroupResult, + MusicalGroupData, +} from './types' +import { resolveShowImageCarousel } from './types' +import type { EntityClassification, ParsedEntityClaims } from './wikidataApi' +import { + classifyEntity, + fetchEditIndicator, + fetchEntityClaims, + resolveEntityLabels, + websiteHost, +} from './wikidataApi' +import { + type OccupationResolution, + personShowsImageCarousel, + primaryOccupationLabel, + resolveOccupations, +} from './resolvePrimaryOccupation' + +function sparseData(id: string, claims: ParsedEntityClaims, images: CarouselImage[] = []): MusicalGroupData { + return { + id, + label: claims.label, + isMusicPerformer: false, + isLocation: false, + isPerson: false, + showImageCarousel: false, + description: claims.description, + genres: [], + images, + enwikiTitle: claims.enwikiTitle, + commonsCategory: claims.commonsCategory, + imageFilename: claims.imageFilename, + } +} + +interface RichExtras { + images?: CarouselImage[] + editIndicator?: EditIndicator + commonsImageCount?: number + commonsImageCountCapped?: boolean +} + +/** Fields shared by performer + location records, given whatever enrichment is ready. */ +function richSharedData(id: string, claims: ParsedEntityClaims, extras: RichExtras = {}) { + return { + id, + label: claims.label, + description: claims.description, + inceptionYear: claims.inceptionYear, + yearKind: claims.yearKind, + websiteUrl: claims.websiteUrl, + websiteHost: claims.websiteUrl ? websiteHost(claims.websiteUrl) : undefined, + images: extras.images ?? [], + editIndicator: extras.editIndicator, + enwikiTitle: claims.enwikiTitle, + commonsCategory: claims.commonsCategory, + imageFilename: claims.imageFilename, + commonsImageCount: extras.commonsImageCount, + commonsImageCountCapped: extras.commonsImageCountCapped, + } +} + +interface IntroMedia { + editIndicator?: EditIndicator + images: CarouselImage[] + commonsImageCount?: number + commonsImageCountCapped?: boolean +} + +/** Commons carousel + image count (+ optional edit indicator) — all non-SPARQL. */ +async function fetchIntroMedia( + id: string, + claims: ParsedEntityClaims, + options: { editIndicator: boolean; signal?: AbortSignal }, +): Promise { + const { signal } = options + const category = resolveCommonsCategory({ + commonsCategory: claims.commonsCategory, + label: claims.label, + }) + + const [editIndicator, carouselResult, categoryInfo] = await Promise.all([ + options.editIndicator + ? fetchEditIndicator(id, signal).catch(() => undefined) + : Promise.resolve(undefined), + fetchCarouselImages({ + label: claims.label, + imageFilename: claims.imageFilename ?? null, + commonsCategory: claims.commonsCategory ?? null, + signal, + }).catch(() => ({ images: [] as CarouselImage[] })), + category + ? getCommonsCategoryCount(category, signal).catch(() => undefined) + : Promise.resolve(undefined), + ]) + + const countMeta = categoryInfo ? commonsImageCountFromCategory(categoryInfo) : undefined + + return { + editIndicator, + images: carouselResult.images, + commonsImageCount: countMeta?.count, + commonsImageCountCapped: countMeta?.capped, + } +} + +function classificationTypeLabel(classification: EntityClassification): string | undefined { + const raw = classification.isMusicPerformer + ? classification.musicTypeLabel + : classification.isLocation + ? classification.locationTypeLabel + : undefined + return raw ? sentenceCase(raw) : undefined +} + +function resolveEntityProfile( + classification: EntityClassification, + occupation: OccupationResolution, +) { + const actorMusician = occupation.primaryIsActor && occupation.hasMusicOccupation + + const isMusicPerformer = + classification.isMusicPerformer && + !actorMusician && + (!classification.isHuman || occupation.primaryIsMusic) + + const isLocation = classification.isLocation + const isPerson = classification.isHuman && !isMusicPerformer && !isLocation + + const showImageCarousel = resolveShowImageCarousel({ + isMusicPerformer, + isLocation, + isPerson, + actorMusician, + personShowsCarousel: personShowsImageCarousel(occupation, actorMusician), + }) + + return { isMusicPerformer, isLocation, isPerson, showImageCarousel, actorMusician } +} + +export async function fetchMusicalGroup( + id: string, + options: FetchMusicalGroupOptions = {}, +): Promise { + const { signal, onPartial } = options + + // Stage 0: entity claims (Action API), classification (WDQS), and occupation + // resolution (P279* on P106) run in parallel where possible. + const [claims, classification] = await Promise.all([ + fetchEntityClaims(id, signal), + classifyEntity(id, signal), + ]) + + const occupation = await resolveOccupations(claims.occupationIds, { + preferredIds: claims.preferredOccupationIds, + signal, + }) + + const profile = resolveEntityProfile(classification, occupation) + const { isMusicPerformer: performer, isLocation: location, isPerson: person, showImageCarousel } = + profile + const typeLabel = classificationTypeLabel(classification) + + // Emit a partial record so the UI can paint the title + facts immediately + // while Stage 1 (images, genres, country, edit indicator) streams in. + if (onPartial) { + if (performer || location || person) { + onPartial({ + ...richSharedData(id, claims), + isMusicPerformer: performer, + isLocation: location, + isPerson: person, + showImageCarousel, + typeLabel: person ? undefined : typeLabel, + genres: [], + ...(location ? { population: claims.population } : {}), + }) + } else { + onPartial(sparseData(id, claims)) + } + } + + // Stage 1: Commons media + label lookups — all Action / Commons API. + if (!performer && !location && !person) { + const media = await fetchIntroMedia(id, claims, { editIndicator: false, signal }) + return { + data: { + ...sparseData(id, claims, media.images), + commonsImageCount: media.commonsImageCount, + commonsImageCountCapped: media.commonsImageCountCapped, + }, + } + } + + if (performer) { + const [media, labelMap] = await Promise.all([ + fetchIntroMedia(id, claims, { editIndicator: true, signal }), + resolveEntityLabels(claims.genreIds, signal).catch(() => new Map()), + ]) + + const genres = claims.genreIds + .map((genreId) => labelMap.get(genreId)) + .filter((label): label is string => Boolean(label)) + + return { + data: { + ...richSharedData(id, claims, media), + isMusicPerformer: true, + isLocation: false, + isPerson: false, + showImageCarousel: true, + typeLabel, + genres, + }, + } + } + + if (person) { + const labelIds = occupation.primaryId + ? [occupation.primaryId] + : claims.occupationIds.slice(0, 1) + + const [media, labelMap] = await Promise.all([ + fetchIntroMedia(id, claims, { editIndicator: true, signal }), + resolveEntityLabels(labelIds, signal).catch(() => new Map()), + ]) + + return { + data: { + ...richSharedData(id, claims, media), + isMusicPerformer: false, + isLocation: false, + isPerson: true, + showImageCarousel, + typeLabel: primaryOccupationLabel(labelMap, occupation), + genres: [], + }, + } + } + + const countryId = + claims.countryId && claims.countryId !== id ? claims.countryId : undefined + + const [media, countryLabel] = await Promise.all([ + fetchIntroMedia(id, claims, { editIndicator: true, signal }), + countryId + ? resolveEntityLabels([countryId], signal) + .then((labels) => labels.get(countryId)) + .catch(() => undefined) + : Promise.resolve(undefined), + ]) + + return { + data: { + ...richSharedData(id, claims, media), + isMusicPerformer: false, + isLocation: true, + isPerson: false, + showImageCarousel: true, + typeLabel, + genres: [], + country: countryLabel, + population: claims.population, + }, + } +} diff --git a/src/prototypes/musical-group/data/fetchMusicalGroupOverview.ts b/src/prototypes/musical-group/data/fetchMusicalGroupOverview.ts new file mode 100644 index 0000000..5765cf2 --- /dev/null +++ b/src/prototypes/musical-group/data/fetchMusicalGroupOverview.ts @@ -0,0 +1,963 @@ +import { loadConfig, PROTOWIKI_API_PROJECT_URL, PROTOWIKI_API_USER_AGENT, wikimediaApiFetchHeaders } from '@/config' +import { fetchWikimedia } from '@/lib/fetchWikimedia' +import { mapWithConcurrency } from '@/lib/mapWithConcurrency' + +import { EN_WIKI_HOST, enwikiTitlesMatch, fetchWikibaseItemId, normalizeEnwikiTitle, wikiActionUrl } from './enwikiTitle' +import { isExcludedEditOpportunityNeed, resolveEditOpportunityCopy } from './editOpportunityCopy' +import { fetchRecentChangeForItem } from './fetchRecentChanges' +import { fetchWithTimeout } from './fetchWithTimeout' +import type { + HomeRecentChange, + HomeSavedItem, + MusicalGroupData, + MusicalGroupInfobox, + MusicalGroupInfoboxRow, + MusicalGroupInfoboxValue, + MusicalGroupOverviewArticle, + MusicalGroupOverviewData, + MusicalGroupOverviewEditOpportunity, + MusicalGroupOverviewRelated, + MusicalGroupOverviewSnippet, +} from './types' +import { normalizeQid } from './wikidataApi' + +const MICROTASK_QUALITY_CHECK_URL = 'https://microtask-generator.toolforge.org/quality-check' +/** Related-reading candidates resolved in parallel per overview load. */ +const MAX_RELATED_CANDIDATES = 4 + +export interface FetchMusicalGroupOverviewOptions { + signal?: AbortSignal + /** Called as each overview stage resolves so the UI can paint progressively. */ + onPartial?: (overview: MusicalGroupOverviewData) => void +} + +interface PageSummaryResponse { + title?: string + description?: string + extract_html?: string + thumbnail?: { source?: string } + timestamp?: string + content_urls?: { desktop?: { page?: string } } +} + +interface SearchHit { + title?: string + wordcount?: number + snippet?: string +} + +interface QualityCheckPotentialNeed { + need?: string + score?: number +} + +interface QualityCheckResult { + title?: string + exists?: boolean + potential_needs?: QualityCheckPotentialNeed[] +} + +function microtaskFetchHeaders(): HeadersInit { + const contact = loadConfig().apiContact.trim() || 'contact unavailable' + const userAgent = `${PROTOWIKI_API_USER_AGENT} (${PROTOWIKI_API_PROJECT_URL}; ${contact}) musical-group-quality-check` + return { + 'Content-Type': 'application/json', + 'User-Agent': userAgent, + } +} + +async function fetchEditOpportunity( + title: string, + signal?: AbortSignal, +): Promise { + try { + const response = await fetchWithTimeout(MICROTASK_QUALITY_CHECK_URL, { + method: 'POST', + signal, + headers: microtaskFetchHeaders(), + body: JSON.stringify({ lang: 'en', titles: [title] }), + }) + if (!response.ok) return undefined + + const json = (await response.json()) as { results?: QualityCheckResult[] } + const result = json.results?.[0] + if (!result?.exists) return undefined + + const needs = (result.potential_needs ?? []) + .filter((item): item is { need: string; score: number } => { + return typeof item.need === 'string' && typeof item.score === 'number' + }) + .sort((a, b) => b.score - a.score) + + const top = needs.find((item) => !isExcludedEditOpportunityNeed(item.need)) + if (!top) return undefined + + const copy = resolveEditOpportunityCopy(top.need) + return { + title: copy.title, + body: copy.body, + need: top.need, + score: top.score, + } + } catch (err) { + if ((err as Error).name === 'AbortError') throw err + return undefined + } +} + +function titleCacheKey(title: string): string { + return normalizeEnwikiTitle(title).toLowerCase() +} + +function parseMediaWikiTimestamp(timestamp: string): Date { + const trimmed = timestamp.trim() + if (!trimmed.length) return new Date(Number.NaN) + if (trimmed.includes('T')) { + return new Date(trimmed.endsWith('Z') ? trimmed : `${trimmed}Z`) + } + return new Date(trimmed.replace(' ', 'T') + 'Z') +} + +function toPageviewDateParam(date: Date): string { + const y = date.getUTCFullYear() + const m = String(date.getUTCMonth() + 1).padStart(2, '0') + const d = String(date.getUTCDate()).padStart(2, '0') + return `${y}${m}${d}` +} + +function yesterdayPageviewDate(): string { + const d = new Date() + d.setUTCDate(d.getUTCDate() - 1) + return toPageviewDateParam(d) +} + +function pageviewsArticleSlug(title: string): string { + return encodeURIComponent(title.replace(/ /g, '_')) +} + +function formatRelativeTime(isoTimestamp: string): string { + const then = parseMediaWikiTimestamp(isoTimestamp).getTime() + if (Number.isNaN(then)) return '—' + const diffMs = Date.now() - then + if (diffMs < 0) return 'just now' + + const minutes = Math.floor(diffMs / (1000 * 60)) + const hours = Math.floor(diffMs / (1000 * 60 * 60)) + const days = Math.floor(diffMs / (1000 * 60 * 60 * 24)) + + if (minutes < 1) return 'just now' + if (minutes < 60) return minutes === 1 ? '1 minute ago' : `${minutes} minutes ago` + if (hours < 24) return hours === 1 ? '1 hour ago' : `${hours} hours ago` + if (days === 1) return '1 day ago' + if (days < 30) return `${days} days ago` + const months = Math.floor(days / 30) + if (months === 1) return '1 month ago' + return `${months} months ago` +} + +function formatViewCount(total: number): string { + if (total >= 1_000_000) return `${(total / 1_000_000).toFixed(1)}M` + if (total >= 1000) return `${(total / 1000).toFixed(1)}k` + return total.toLocaleString() +} + +function startOfIsoWeekUtc(date: Date): Date { + const d = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())) + const day = d.getUTCDay() + const diff = day === 0 ? -6 : 1 - day + d.setUTCDate(d.getUTCDate() + diff) + return d +} + +function deadLinkExtractHtml(html: string): string { + return html + .replace(/]*>([\s\S]*?)<\/a>/gi, '$1') + .replace(/<\/?b>/gi, '') + .replace(/<\/?strong>/gi, '') +} + +async function fetchPageSummary(title: string, signal?: AbortSignal): Promise { + const slug = encodeURIComponent(title.replace(/ /g, '_')) + const response = await fetchWikimedia(`https://${EN_WIKI_HOST}/api/rest_v1/page/summary/${slug}`, { + signal, + headers: wikimediaApiFetchHeaders('musical-group-page-summary'), + }) + if (!response.ok) return null + return (await response.json()) as PageSummaryResponse +} + +async function fetchMorelikeHits( + seedTitle: string, + signal?: AbortSignal, + limit = 5, +): Promise { + const url = wikiActionUrl({ + action: 'query', + list: 'search', + srsearch: `morelike:${seedTitle}`, + srwhat: 'text', + srnamespace: '0', + srlimit: String(limit), + }) + + const response = await fetchWikimedia(url, { + signal, + headers: wikimediaApiFetchHeaders('musical-group-morelike'), + }) + if (!response.ok) return [] + + const json = (await response.json()) as { query?: { search?: SearchHit[] } } + return json.query?.search ?? [] +} + +function isSameOverviewItem( + itemId: string | undefined, + title: string | undefined, + excludeItemId?: string, + excludeTitle?: string, +): boolean { + if (excludeItemId && itemId && normalizeQid(itemId) === normalizeQid(excludeItemId)) { + return true + } + if (excludeTitle && title && enwikiTitlesMatch(title, excludeTitle)) { + return true + } + return false +} + +async function tryRelatedCandidate( + relatedTitle: string, + seedTitle: string, + relatedToTitle: string, + excludeItemId: string | undefined, + signal?: AbortSignal, +): Promise { + const [summary, views, wikibaseId] = await Promise.all([ + fetchPageSummary(relatedTitle, signal), + resolvePageviewsLabel(relatedTitle, signal), + fetchWikibaseItemId(relatedTitle, signal), + ]) + + const resolvedTitle = summary?.title ?? relatedTitle + if ( + isSameOverviewItem(wikibaseId, resolvedTitle, excludeItemId, seedTitle) || + isSameOverviewItem(wikibaseId, relatedTitle, excludeItemId, seedTitle) + ) { + return undefined + } + + const timestamp = summary?.timestamp ?? '' + const relative = timestamp ? formatRelativeTime(timestamp) : '—' + + return { + id: wikibaseId, + title: resolvedTitle, + description: summary?.description ?? '', + thumbnailUrl: summary?.thumbnail?.source, + articleUrl: + summary?.content_urls?.desktop?.page ?? + `https://${EN_WIKI_HOST}/wiki/${pageviewsArticleSlug(relatedTitle)}`, + lastEditedTimestamp: timestamp, + lastEditedLabel: timestamp ? `Updated ${relative}` : 'Updated —', + viewCount: views.total, + viewsLabel: views.label, + relatedToTitle, + } +} + +function normalizeTitle(title: string): string { + return title.replace(/ /g, '_').toLowerCase() +} + +async function fetchMorelikeRelated( + seedTitle: string, + relatedToTitle: string, + excludeItemId: string | undefined, + signal?: AbortSignal, + prefetchedHits?: SearchHit[], +): Promise { + const hits = prefetchedHits ?? (await fetchMorelikeHits(seedTitle, signal, 10)) + const seen = new Set([titleCacheKey(seedTitle)]) + + const candidates = hits + .map((hit) => hit.title) + .filter((title): title is string => Boolean(title)) + .filter((title) => { + const key = titleCacheKey(title) + if (seen.has(key)) return false + seen.add(key) + return true + }) + .slice(0, MAX_RELATED_CANDIDATES) + + if (!candidates.length) return undefined + + const results = await mapWithConcurrency( + candidates, + 3, + (relatedTitle) => + tryRelatedCandidate(relatedTitle, seedTitle, relatedToTitle, excludeItemId, signal).catch( + (err) => { + if ((err as Error).name === 'AbortError') throw err + return undefined + }, + ), + signal, + ) + + return results.find((result): result is MusicalGroupOverviewRelated => result !== undefined) +} + +async function fetchSnippetHits( + seedTitle: string, + signal?: AbortSignal, +): Promise { + const url = wikiActionUrl({ + action: 'query', + list: 'search', + srsearch: `"${seedTitle}"`, + srwhat: 'text', + srnamespace: '0', + srprop: 'snippet', + srlimit: '50', + }) + + const response = await fetchWikimedia(url, { + signal, + headers: wikimediaApiFetchHeaders('musical-group-snippet'), + }) + if (!response.ok) return [] + + const json = (await response.json()) as { query?: { search?: SearchHit[] } } + return json.query?.search ?? [] +} + +function isLowValueMentionTitle(title: string): boolean { + return ( + /^Main Page$/i.test(title) || + /^(List|Index|Outline|Timeline|Glossary|Comparison|Bibliography) of\b/i.test(title) || + /\bdiscography\b/i.test(title) || + /\(disambiguation\)$/i.test(title) || + /^\d{3,4} in\b/i.test(title) + ) +} + +function formatSnippetHtml(html: string): string { + const formatted = html + .replace(/<\/span>([^\S\r\n]+)/g, '$1') + .replace(/\s*[\r\n]+\s*/g, ' … ') + .trim() + if (!formatted) return formatted + return /^\s*(…|\.\.\.)/.test(formatted) ? formatted : `… ${formatted}` +} + +async function isUsableMentionTitle( + title: string | undefined, + ownTitle: string, + excludeItemId: string | undefined, + signal?: AbortSignal, +): Promise { + if (!title || enwikiTitlesMatch(title, ownTitle) || isLowValueMentionTitle(title)) { + return false + } + if (!excludeItemId) return true + const wikibaseId = await fetchWikibaseItemId(title, signal) + return !isSameOverviewItem(wikibaseId, title, excludeItemId, ownTitle) +} + +async function fetchSnippetMention( + searchTerm: string, + ownTitle: string, + excludeItemId: string | undefined, + signal?: AbortSignal, + prefetchedMorelikeHits?: SearchHit[], +): Promise { + const [morelikeHits, mentionHits] = await Promise.all([ + prefetchedMorelikeHits + ? Promise.resolve(prefetchedMorelikeHits) + : fetchMorelikeHits(ownTitle, signal, 15), + fetchSnippetHits(searchTerm, signal), + ]) + + const snippetByTitle = new Map() + for (const candidate of mentionHits) { + if (candidate.title && candidate.snippet) { + snippetByTitle.set(normalizeTitle(candidate.title), candidate.snippet) + } + } + + let mentionTitle: string | undefined + let snippet: string | undefined + + for (const candidate of morelikeHits) { + if (!(await isUsableMentionTitle(candidate.title, ownTitle, excludeItemId, signal))) continue + const related = snippetByTitle.get(normalizeTitle(candidate.title as string)) + if (!related) continue + mentionTitle = candidate.title + snippet = related + break + } + + if (!mentionTitle) { + for (const candidate of mentionHits) { + if (!(await isUsableMentionTitle(candidate.title, ownTitle, excludeItemId, signal))) { + continue + } + if (!candidate.snippet) continue + mentionTitle = candidate.title + snippet = candidate.snippet + break + } + } + + if (!mentionTitle || !snippet) return undefined + + const [summary, wikibaseId] = await Promise.all([ + fetchPageSummary(mentionTitle, signal), + fetchWikibaseItemId(mentionTitle, signal), + ]) + + if (isSameOverviewItem(wikibaseId, summary?.title ?? mentionTitle, excludeItemId, ownTitle)) { + return undefined + } + + return { + id: wikibaseId, + title: summary?.title ?? mentionTitle, + description: summary?.description ?? '', + snippetHtml: formatSnippetHtml(snippet), + thumbnailUrl: summary?.thumbnail?.source, + articleUrl: + summary?.content_urls?.desktop?.page ?? + `https://${EN_WIKI_HOST}/wiki/${pageviewsArticleSlug(mentionTitle)}`, + } +} + +function overviewSavedItem(data: MusicalGroupData): HomeSavedItem | undefined { + if (!data.enwikiTitle) return undefined + return { + id: data.id, + title: data.label, + enwikiTitle: data.enwikiTitle, + description: data.description ?? '', + thumbnailUrl: data.images[0]?.url, + savedAt: 0, + } +} + +async function fetchOverviewLatestEdit( + data: MusicalGroupData, + signal?: AbortSignal, +): Promise { + const item = overviewSavedItem(data) + if (!item) return undefined + + const change = await fetchRecentChangeForItem(item, signal).catch((err) => { + if ((err as Error).name === 'AbortError') throw err + return null + }) + return change ?? undefined +} + +function collapseWhitespace(text: string): string { + return text.replace(/\s+/g, ' ').trim() +} + +function cleanInfoboxCell(scope: Element): void { + scope + .querySelectorAll('style, sup.reference, .mw-editsection, .mw-valign-text-top') + .forEach((node) => node.remove()) + scope.querySelectorAll('.ib-settlement-fn sup:not(.reference)').forEach((node) => node.remove()) +} + +function cellLabel(cell: Element): string { + const clone = cell.cloneNode(true) as Element + cleanInfoboxCell(clone) + return normalizeInfoboxLabel(collapseWhitespace(clone.textContent ?? '')) +} + +function normalizeInfoboxLabel(label: string): string { + return label.replace(/\s*:\s*$/, '').trim() +} + +function consolidateInfoboxRows(rows: MusicalGroupInfoboxRow[]): MusicalGroupInfoboxRow[] { + const consolidated: MusicalGroupInfoboxRow[] = [] + + for (const row of rows) { + const label = normalizeInfoboxLabel(row.label) + if (!label) continue + + const normalized = { ...row, label } + + if (row.variant === 'header') { + consolidated.push(normalized) + continue + } + + const previous = consolidated[consolidated.length - 1] + if (previous && previous.variant !== 'header' && previous.label === label) { + previous.values.push(...row.values) + continue + } + + consolidated.push(normalized) + } + + return consolidated +} + +function splitOnBreaks(cell: Element): Element[] { + if (!cell.querySelector('br')) return [cell] + + const segments: Element[] = [] + let bucket = cell.ownerDocument!.createElement('span') + + for (const node of Array.from(cell.childNodes)) { + if (node.nodeName === 'BR') { + if (collapseWhitespace(bucket.textContent ?? '')) segments.push(bucket) + bucket = cell.ownerDocument!.createElement('span') + continue + } + bucket.appendChild(node.cloneNode(true)) + } + + if (collapseWhitespace(bucket.textContent ?? '')) segments.push(bucket) + return segments.length ? segments : [cell] +} + +function externalHref(scope: Element): string | undefined { + const anchors = Array.from(scope.querySelectorAll('a')) + for (const anchor of anchors) { + const href = anchor.getAttribute('href') ?? '' + if (!href) continue + if (anchor.classList.contains('external') || /^https?:\/\//i.test(href)) { + return href.startsWith('//') ? `https:${href}` : href + } + } + return undefined +} + +function toValue(scope: Element): MusicalGroupInfoboxValue | null { + const text = collapseWhitespace(scope.textContent ?? '') + if (!text) return null + const href = externalHref(scope) + return href ? { text, href } : { text } +} + +function cellValues(cell: Element): MusicalGroupInfoboxValue[] { + const clone = cell.cloneNode(true) as Element + cleanInfoboxCell(clone) + + const listItems = Array.from(clone.querySelectorAll('li')) + if (listItems.length) { + return listItems + .map((item) => toValue(item)) + .filter((value): value is MusicalGroupInfoboxValue => value !== null) + } + + const segments = splitOnBreaks(clone) + if (segments.length > 1) { + return segments + .map((segment) => toValue(segment)) + .filter((value): value is MusicalGroupInfoboxValue => value !== null) + } + + const single = toValue(clone) + return single ? [single] : [] +} + +function directRowCells(row: Element): { ths: Element[]; tds: Element[] } { + return { + ths: Array.from(row.children).filter((cell) => cell.tagName === 'TH'), + tds: Array.from(row.children).filter((cell) => cell.tagName === 'TD'), + } +} + +function hasColspanTwo(cell: Element): boolean { + return cell.getAttribute('colspan') === '2' +} + +function isIndentedValueDiv(element: Element): boolean { + return (element.getAttribute('style') ?? '').includes('padding-left') +} + +function colspanTwoCell(row: Element): Element | undefined { + const { ths, tds } = directRowCells(row) + if (ths.length !== 0 || tds.length !== 1 || !hasColspanTwo(tds[0])) return undefined + return tds[0] +} + +function isColspanTwoDataRow(row: Element): boolean { + const cell = colspanTwoCell(row) + if (!cell) return false + if (Array.from(cell.querySelectorAll('div')).some(isIndentedValueDiv)) return false + + const values = cellValues(cell) + return values.length > 0 +} + +function parseStackedColspanRow(cell: Element): MusicalGroupInfoboxRow | undefined { + const valueDivs = Array.from(cell.querySelectorAll('div')).filter(isIndentedValueDiv) + if (!valueDivs.length) return undefined + + const values = valueDivs.flatMap((valueDiv) => cellValues(valueDiv)) + if (!values.length) return undefined + + const labelCell = cell.cloneNode(true) as Element + labelCell.querySelectorAll('div').forEach((div) => { + if (isIndentedValueDiv(div)) div.remove() + }) + + const label = cellLabel(labelCell) + if (!label) return undefined + + return { label, values } +} + +function parseStandardInfoboxRows(infobox: Element): MusicalGroupInfoboxRow[] { + const rows: MusicalGroupInfoboxRow[] = [] + + for (const row of Array.from(infobox.querySelectorAll('tr'))) { + const labelCell = row.querySelector('.infobox-label') + const dataCell = row.querySelector('.infobox-data') + if (!labelCell || !dataCell) continue + + const label = cellLabel(labelCell) + if (!label) continue + + const values = cellValues(dataCell) + if (!values.length) continue + + rows.push({ label, values }) + } + + return rows +} + +function parseLegacyInfoboxRows(infobox: Element): MusicalGroupInfoboxRow[] { + const tableRows = Array.from(infobox.querySelectorAll('tr')) + const rows: MusicalGroupInfoboxRow[] = [] + + for (let index = 0; index < tableRows.length; index++) { + const row = tableRows[index] + const { ths, tds } = directRowCells(row) + + if (ths.length === 1 && tds.length === 0 && hasColspanTwo(ths[0])) { + const label = cellLabel(ths[0]) + if (!label) continue + + const nextRow = tableRows[index + 1] + if (nextRow && isColspanTwoDataRow(nextRow)) { + const cell = colspanTwoCell(nextRow) + const values = cell ? cellValues(cell) : [] + if (values.length) rows.push({ label, values }) + index++ + continue + } + + rows.push({ label, values: [], variant: 'header' }) + continue + } + + if (tds.length === 1 && hasColspanTwo(tds[0])) { + const stacked = parseStackedColspanRow(tds[0]) + if (stacked) rows.push(stacked) + continue + } + + if (tds.length === 2 && ths.length === 0) { + const label = cellLabel(tds[0]) + const values = cellValues(tds[1]) + if (!label || !values.length) continue + + rows.push({ label, values }) + } + } + + return rows +} + +async function fetchInfobox(title: string, signal?: AbortSignal): Promise { + const url = wikiActionUrl({ + action: 'parse', + page: title, + prop: 'text', + section: '0', + disablelimitreport: '1', + disableeditsection: '1', + }) + + const response = await fetchWikimedia(url, { + signal, + headers: wikimediaApiFetchHeaders('musical-group-infobox'), + }) + if (!response.ok) return undefined + + const json = (await response.json()) as { parse?: { text?: { '*'?: string } } } + const html = json.parse?.text?.['*'] + if (!html) return undefined + + const doc = new DOMParser().parseFromString(html, 'text/html') + const infobox = doc.querySelector('table.infobox') + if (!infobox) return undefined + + const standard = parseStandardInfoboxRows(infobox) + const parsed = standard.length ? standard : parseLegacyInfoboxRows(infobox) + const rows = consolidateInfoboxRows(parsed) + + if (!rows.length) return undefined + return { rows } +} + +async function fetchArticleWordCount(title: string, signal?: AbortSignal): Promise { + const url = wikiActionUrl({ + action: 'query', + list: 'search', + srsearch: title, + srnamespace: '0', + srlimit: '5', + }) + + const response = await fetchWikimedia(url, { + signal, + headers: wikimediaApiFetchHeaders('musical-group-wordcount'), + }) + if (!response.ok) return undefined + + const json = (await response.json()) as { query?: { search?: SearchHit[] } } + const hits = json.query?.search ?? [] + const normalized = title.replace(/ /g, '_').toLowerCase() + const match = + hits.find((hit) => hit.title?.replace(/ /g, '_').toLowerCase() === normalized) ?? hits[0] + return match?.wordcount +} + +function sumPageviewsInRange( + pageviews: Record, + start: string, + end: string, +): number { + let total = 0 + for (const [isoDate, views] of Object.entries(pageviews)) { + if (views == null) continue + const ymd = isoDate.replace(/-/g, '') + if (ymd >= start && ymd <= end) { + total += views + } + } + return total +} + +async function fetchArticlePageviews( + title: string, + signal?: AbortSignal, +): Promise<{ pageviews: Record; ok: boolean }> { + const url = wikiActionUrl({ + action: 'query', + prop: 'pageviews', + titles: title, + pvipdays: '31', + }) + + try { + const response = await fetchWikimedia(url, { + signal, + headers: wikimediaApiFetchHeaders('musical-group-pageviews'), + }) + if (!response.ok) { + return { pageviews: {}, ok: false } + } + + const json = (await response.json()) as { + query?: { pages?: Record }> } + } + const page = Object.values(json.query?.pages ?? {})[0] + if (!page || page.missing) { + return { pageviews: {}, ok: false } + } + + return { pageviews: page.pageviews ?? {}, ok: true } + } catch (err) { + if ((err as Error).name === 'AbortError') throw err + return { pageviews: {}, ok: false } + } +} + +async function resolvePageviewsLabel( + title: string, + signal?: AbortSignal, +): Promise<{ total: number; label: string }> { + const fetched = await fetchArticlePageviews(title, signal) + if (!fetched.ok) { + return { total: 0, label: '—' } + } + + const { pageviews } = fetched + const end = yesterdayPageviewDate() + const yesterday = new Date() + yesterday.setUTCDate(yesterday.getUTCDate() - 1) + + const weekStart = toPageviewDateParam(startOfIsoWeekUtc(yesterday)) + const weekTotal = sumPageviewsInRange(pageviews, weekStart, end) + if (weekTotal > 0) { + return { + total: weekTotal, + label: `${formatViewCount(weekTotal)} views this week`, + } + } + + const sevenDaysAgo = new Date(yesterday) + sevenDaysAgo.setUTCDate(sevenDaysAgo.getUTCDate() - 6) + const sevenStart = toPageviewDateParam(sevenDaysAgo) + const sevenTotal = sumPageviewsInRange(pageviews, sevenStart, end) + if (sevenTotal > 0) { + return { + total: sevenTotal, + label: `${formatViewCount(sevenTotal)} views in the last 7 days`, + } + } + + const thirtyDaysAgo = new Date(yesterday) + thirtyDaysAgo.setUTCDate(thirtyDaysAgo.getUTCDate() - 29) + const monthStart = toPageviewDateParam(thirtyDaysAgo) + const monthTotal = sumPageviewsInRange(pageviews, monthStart, end) + if (monthTotal > 0) { + return { + total: monthTotal, + label: `${formatViewCount(monthTotal)} views this month`, + } + } + + return { total: 0, label: '—' } +} + +function buildOverviewArticle( + summary: PageSummaryResponse, + title: string, + extras: { + wordCount?: number + views?: { total: number; label: string } + } = {}, +): MusicalGroupOverviewArticle { + const timestamp = summary.timestamp ?? '' + const relative = timestamp ? formatRelativeTime(timestamp) : '—' + const wordCount = extras.wordCount ?? 0 + + return { + title: summary.title ?? title, + extractHtml: deadLinkExtractHtml(summary.extract_html ?? ''), + thumbnailUrl: summary.thumbnail?.source, + articleUrl: + summary.content_urls?.desktop?.page ?? + `https://${EN_WIKI_HOST}/wiki/${pageviewsArticleSlug(title)}`, + lastEditedTimestamp: timestamp, + lastEditedLabel: timestamp ? `Updated ${relative}` : 'Updated —', + viewCount: extras.views?.total ?? 0, + viewsLabel: extras.views?.label ?? '—', + wordCount, + wordCountLabel: wordCount ? `${wordCount.toLocaleString()} words` : '', + } +} + +export async function fetchMusicalGroupOverview( + data: MusicalGroupData, + options: FetchMusicalGroupOverviewOptions = {}, +): Promise { + const { signal, onPartial } = options + const fetchedAt = Date.now() + + const emit = (overview: MusicalGroupOverviewData) => { + onPartial?.({ ...overview, fetchedAt }) + } + + if (!data.enwikiTitle) { + const result: MusicalGroupOverviewData = { noEnglishArticle: true, fetchedAt } + emit(result) + return result + } + + const title = data.enwikiTitle + + const summary = await fetchPageSummary(title, signal) + if (!summary) { + const result: MusicalGroupOverviewData = { noEnglishArticle: true, fetchedAt } + emit(result) + return result + } + + let overview: MusicalGroupOverviewData = { + article: buildOverviewArticle(summary, title), + fetchedAt, + } + emit(overview) + + const [wordCount, views] = await Promise.all([ + fetchArticleWordCount(title, signal), + resolvePageviewsLabel(title, signal), + ]) + + overview = { + ...overview, + article: buildOverviewArticle(summary, title, { wordCount, views }), + } + emit(overview) + + const patch = (partial: Partial) => { + overview = { ...overview, ...partial } + emit(overview) + } + + const morelikeHitsPromise = fetchMorelikeHits(title, signal, 15).catch((err) => { + if ((err as Error).name === 'AbortError') throw err + return [] as SearchHit[] + }) + + await Promise.all([ + fetchInfobox(title, signal) + .catch((err) => { + if ((err as Error).name === 'AbortError') throw err + return undefined + }) + .then((infobox) => { + if (infobox) patch({ infobox }) + }), + morelikeHitsPromise.then((hits) => + fetchMorelikeRelated(title, data.label, data.id, signal, hits) + .catch((err) => { + if ((err as Error).name === 'AbortError') throw err + return undefined + }) + .then((related) => { + if (related) patch({ related }) + }), + ), + morelikeHitsPromise.then((hits) => + fetchSnippetMention(data.label, title, data.id, signal, hits) + .catch((err) => { + if ((err as Error).name === 'AbortError') throw err + return undefined + }) + .then((snippet) => { + if (snippet) patch({ snippet }) + }), + ), + fetchEditOpportunity(title, signal) + .catch((err) => { + if ((err as Error).name === 'AbortError') throw err + return undefined + }) + .then((editOpportunity) => { + if (editOpportunity) patch({ editOpportunity }) + }), + fetchOverviewLatestEdit(data, signal) + .catch((err) => { + if ((err as Error).name === 'AbortError') throw err + return undefined + }) + .then((latestEdit) => { + if (latestEdit) patch({ latestEdit }) + }), + ]) + + return overview +} diff --git a/src/prototypes/musical-group/data/fetchRecentChanges.ts b/src/prototypes/musical-group/data/fetchRecentChanges.ts new file mode 100644 index 0000000..aac0779 --- /dev/null +++ b/src/prototypes/musical-group/data/fetchRecentChanges.ts @@ -0,0 +1,620 @@ +import { wikimediaApiFetchHeaders } from '@/config' +import { fetchWikimedia } from '@/lib/fetchWikimedia' +import { mapWithConcurrency } from '@/lib/mapWithConcurrency' + +import { bookmarksKey } from './cacheKeys' +import { EN_WIKI_HOST, normalizeEnwikiTitle, wikiActionUrl } from './enwikiTitle' +import { formatEditSummaryDisplay } from './editSummaryDisplay' +import { getCachedRecentChangesPreview, setCachedRecentChangesPreview } from './homeTabCache' +import { predictGoodFaith, predictReferenceNeed, predictRevertRisk, predictTone } from './liftWing' +import type { HomeRecentChange, HomeRecentChangeFlag, HomeSavedItem } from './types' + +/** How many saved pages to show in the home Activity preview. */ +const MAX_RECENT_CHANGES = 2 +/** Titles per batch revision metadata query. */ +const REVISION_BATCH_SIZE = 50 +/** Revisions fetched per saved page per Activity feed page. */ +export const ACTIVITY_REVISIONS_PER_FETCH = 5 +const TONE_THRESHOLD = 0.8 +const REVERT_RISK_THRESHOLD = 0.7 +/** Child-minus-parent reference-need increase that flags an edit. */ +const REFERENCE_NEED_DELTA_THRESHOLD = 0.05 +/** Edit Check addReference minimum net new visible text length. */ +const UNSOURCED_ADDITION_MIN_CHARS = 50 +/** Edit count below which a registered editor is treated as a newcomer. */ +const NEW_EDITOR_MAX_EDITS = 10 +const DIFF_TEXT_LIMIT = 2000 + +export interface LatestRevision { + revid: number + parentid: number + user: string + userid: number + comment: string + parsedComment: string + anon: boolean + timestamp: string + reverted: boolean +} + +export interface ActivityCandidate { + item: HomeSavedItem + revision: LatestRevision +} + +type RevisionApiRow = { + revid?: number + parentid?: number + user?: string + userid?: number + comment?: string + parsedcomment?: string + anon?: string + timestamp?: string + tags?: string[] +} + +type RevisionPageRow = { + title?: string + missing?: string + revisions?: RevisionApiRow[] +} + +function titleKey(title: string): string { + return normalizeEnwikiTitle(title).toLowerCase() +} + +function parseRevisionRow(revision: RevisionApiRow): LatestRevision | null { + if (!revision.revid) return null + + return { + revid: revision.revid, + parentid: revision.parentid ?? 0, + user: revision.user ?? '', + userid: revision.userid ?? 0, + comment: revision.comment ?? '', + parsedComment: revision.parsedcomment ?? '', + anon: revision.anon !== undefined, + timestamp: revision.timestamp ?? '', + reverted: (revision.tags ?? []).includes('mw-reverted'), + } +} + +function diffUrl(title: string, revid: number): string { + const params = new URLSearchParams({ + title: title.replace(/ /g, '_'), + diff: 'prev', + oldid: String(revid), + }) + return `https://${EN_WIKI_HOST}/w/index.php?${params.toString()}` +} + +function parseMediaWikiTimestamp(timestamp: string): Date { + const trimmed = timestamp.trim() + if (!trimmed.length) return new Date(Number.NaN) + if (trimmed.includes('T')) { + return new Date(trimmed.endsWith('Z') ? trimmed : `${trimmed}Z`) + } + return new Date(trimmed.replace(' ', 'T') + 'Z') +} + +function formatRelativeTime(isoTimestamp: string): string { + const then = parseMediaWikiTimestamp(isoTimestamp).getTime() + if (Number.isNaN(then)) return '—' + const diffMs = Date.now() - then + if (diffMs < 0) return 'just now' + + const minutes = Math.floor(diffMs / (1000 * 60)) + const hours = Math.floor(diffMs / (1000 * 60 * 60)) + const days = Math.floor(diffMs / (1000 * 60 * 60 * 24)) + + if (minutes < 1) return 'just now' + if (minutes < 60) return minutes === 1 ? '1 minute ago' : `${minutes} minutes ago` + if (hours < 24) return hours === 1 ? '1 hour ago' : `${hours} hours ago` + if (days === 1) return '1 day ago' + if (days < 30) return `${days} days ago` + const months = Math.floor(days / 30) + if (months === 1) return '1 month ago' + return `${months} months ago` +} + +export function formatEditMetaLabel(timestamp: string, user: string): string { + const relative = formatRelativeTime(timestamp) + const editor = user.trim() || 'Anonymous' + return `${relative} by ${editor}` +} + +/** Footer status for activity cards. */ +export function formatEditStatusLabel( + reverted: boolean, + _isLatest: boolean, +): string { + if (reverted) return 'Reverted' + return '' +} + +/** MediaWiki temporary accounts use a ~-prefixed auto-generated username. */ +export function isTemporaryUser(user: string): boolean { + return user.startsWith('~') +} + +export interface PageActivityState { + item: HomeSavedItem & { enwikiTitle: string } + /** Oldest revid already queued; the next fetch starts before this id. */ + oldestRevid?: number + exhausted: boolean +} + +async function fetchLatestRevision( + title: string, + signal?: AbortSignal, +): Promise { + const revisions = await fetchRevisionsForTitle(title, 1, undefined, signal) + return revisions[0] ?? null +} + +/** + * Fetch up to `limit` revisions for one title. Pass `olderThanRevid` to page + * backward through history (MediaWiki returns newest first). + */ +export async function fetchRevisionsForTitle( + title: string, + limit: number, + olderThanRevid?: number, + signal?: AbortSignal, +): Promise { + const params: Record = { + action: 'query', + prop: 'revisions', + titles: title, + rvprop: 'ids|timestamp|user|userid|comment|parsedcomment|tags|flags', + rvlimit: String(limit), + } + if (olderThanRevid != null) { + params.rvstartid = String(olderThanRevid) + params.rvdir = 'older' + } + + const response = await fetchWikimedia(wikiActionUrl(params), { + signal, + headers: wikimediaApiFetchHeaders('musical-group-activity'), + }) + if (!response.ok) return [] + + const json = (await response.json()) as { + query?: { pages?: Record } + } + + for (const page of Object.values(json.query?.pages ?? {})) { + if (page.missing !== undefined || !page.title) continue + return (page.revisions ?? []) + .map((row) => parseRevisionRow(row)) + .filter((revision): revision is LatestRevision => revision !== null) + } + + return [] +} + +/** Batch-fetch the latest revision metadata for up to 50 titles per API call. */ +export async function fetchLatestRevisionsForTitles( + titles: string[], + signal?: AbortSignal, +): Promise<{ revisions: Map; lookupFailed: boolean }> { + const revisions = new Map() + if (!titles.length) return { revisions, lookupFailed: false } + + let anyOk = false + let anyFailed = false + + for (let offset = 0; offset < titles.length; offset += REVISION_BATCH_SIZE) { + const batch = titles.slice(offset, offset + REVISION_BATCH_SIZE) + const url = wikiActionUrl({ + action: 'query', + prop: 'revisions', + titles: batch.join('|'), + rvprop: 'ids|timestamp|user|userid|comment|parsedcomment|tags|flags', + rvlimit: '1', + }) + + const response = await fetchWikimedia(url, { + signal, + headers: wikimediaApiFetchHeaders('musical-group-activity'), + }) + if (!response.ok) { + anyFailed = true + continue + } + + anyOk = true + + const json = (await response.json()) as { + query?: { pages?: Record } + } + + for (const page of Object.values(json.query?.pages ?? {})) { + if (page.missing !== undefined || !page.title) continue + const revision = parseRevisionRow(page.revisions?.[0] ?? {}) + if (!revision) continue + revisions.set(titleKey(page.title), revision) + } + } + + return { revisions, lookupFailed: anyFailed && !anyOk } +} + +export function initPageActivityStates( + items: HomeSavedItem[], +): PageActivityState[] { + return items + .filter((item): item is HomeSavedItem & { enwikiTitle: string } => Boolean(item.enwikiTitle)) + .map((item) => ({ item, exhausted: false })) +} + +/** + * Fetch the next batch of revisions for each non-exhausted saved page, merge + * into candidates sorted by edit timestamp descending. + */ +export async function fetchNextActivityCandidates( + pageStates: PageActivityState[], + seenRevids: Set, + signal?: AbortSignal, + limit = ACTIVITY_REVISIONS_PER_FETCH, +): Promise { + const activePages = pageStates.filter((state) => !state.exhausted) + if (!activePages.length) return [] + + const batches = await mapWithConcurrency( + activePages, + 2, + async (state) => { + const revisions = await fetchRevisionsForTitle( + state.item.enwikiTitle, + limit, + state.oldestRevid, + signal, + ) + return { state, revisions } + }, + signal, + ) + + const candidates: ActivityCandidate[] = [] + + for (const { state, revisions } of batches) { + if (!revisions.length) { + state.exhausted = true + continue + } + + state.oldestRevid = revisions[revisions.length - 1].revid + if (revisions.length < limit) { + state.exhausted = true + } + + for (const revision of revisions) { + if (seenRevids.has(revision.revid)) continue + seenRevids.add(revision.revid) + candidates.push({ item: state.item, revision }) + } + } + + candidates.sort((a, b) => b.revision.timestamp.localeCompare(a.revision.timestamp)) + return candidates +} + +async function fetchEditorEditCount( + user: string, + signal?: AbortSignal, +): Promise { + if (!user) return undefined + + const url = wikiActionUrl({ + action: 'query', + list: 'users', + ususers: user, + usprop: 'editcount|registration', + }) + + const response = await fetchWikimedia(url, { + signal, + headers: wikimediaApiFetchHeaders('musical-group-user-info'), + }) + if (!response.ok) return undefined + + const json = (await response.json()) as { + query?: { users?: { editcount?: number; missing?: string; invalid?: string }[] } + } + const info = json.query?.users?.[0] + if (!info || info.missing !== undefined || info.invalid !== undefined) return undefined + return typeof info.editcount === 'number' ? info.editcount : undefined +} + +function collectText(nodes: NodeListOf): string { + const parts: string[] = [] + for (const node of Array.from(nodes)) { + const text = node.textContent?.trim() + if (text) parts.push(text) + } + return parts.join(' ').slice(0, DIFF_TEXT_LIMIT) +} + +function collectWikitext(nodes: NodeListOf): string { + const parts: string[] = [] + for (const node of Array.from(nodes)) { + const text = node.textContent?.trim() + if (text) parts.push(text) + } + return parts.join('\n').slice(0, DIFF_TEXT_LIMIT) +} + +/** Strip wikitext markup for a rough visible-character count. */ +function visibleWikitextLength(wikitext: string): number { + return wikitext + .replace(/\[\[[^\]|]+\|([^\]]+)\]\]/g, '$1') + .replace(/\[\[([^\]]+)\]\]/g, '$1') + .replace(/''+/g, '') + .replace(/<[^>]+>/g, '') + .replace(/\s+/g, ' ') + .trim().length +} + +/** Edit Check–style rule: substantial net-new prose without a citation. */ +function unsourcedNetAdditionNeedsReference(addedWikitext: string, removedWikitext: string): boolean { + const trimmedAdded = addedWikitext.trim() + if (!trimmedAdded) return false + + const netGrowth = + visibleWikitextLength(trimmedAdded) - visibleWikitextLength(removedWikitext.trim()) + // Compare API emits whole changed lines as added; require real net-new text. + if (netGrowth < UNSOURCED_ADDITION_MIN_CHARS) return false + + if (/]/i.test(trimmedAdded)) return false + if (/^\[\[(Category|File|Image|WP|Wikipedia):[^\]]+\]\]\s*$/i.test(trimmedAdded)) return false + return true +} + +export interface RevisionDiff { + addedPlain: string + removedPlain: string + addedWikitext: string + removedWikitext: string +} + +async function fetchRevisionDiff( + fromRev: number, + toRev: number, + signal?: AbortSignal, +): Promise { + const url = wikiActionUrl({ + action: 'compare', + fromrev: String(fromRev), + torev: String(toRev), + prop: 'diff', + }) + + const response = await fetchWikimedia(url, { + signal, + headers: wikimediaApiFetchHeaders('musical-group-compare'), + }) + if (!response.ok) { + return { addedPlain: '', removedPlain: '', addedWikitext: '', removedWikitext: '' } + } + + const json = (await response.json()) as { compare?: { '*'?: string } } + const html = json.compare?.['*'] + if (!html) { + return { addedPlain: '', removedPlain: '', addedWikitext: '', removedWikitext: '' } + } + + const doc = new DOMParser().parseFromString(`${html}
`, 'text/html') + return { + addedPlain: collectText(doc.querySelectorAll('.diff-addedline')), + removedPlain: collectText(doc.querySelectorAll('.diff-deletedline')), + addedWikitext: collectWikitext(doc.querySelectorAll('.diff-addedline')), + removedWikitext: collectWikitext(doc.querySelectorAll('.diff-deletedline')), + } +} + +function diffNetGrowth(diff: RevisionDiff): number { + return ( + visibleWikitextLength(diff.addedWikitext) - visibleWikitextLength(diff.removedWikitext) + ) +} + +async function needsReferenceFlag( + revision: LatestRevision, + diff: RevisionDiff | undefined, + signal?: AbortSignal, +): Promise { + const netGrowth = diff ? diffNetGrowth(diff) : 0 + if (netGrowth < UNSOURCED_ADDITION_MIN_CHARS) return false + + if (revision.parentid) { + const [childScore, parentScore] = await Promise.all([ + predictReferenceNeed(revision.revid, 'en', signal), + predictReferenceNeed(revision.parentid, 'en', signal), + ]) + if ( + childScore != null && + parentScore != null && + childScore - parentScore >= REFERENCE_NEED_DELTA_THRESHOLD + ) { + return true + } + } + + if (diff && unsourcedNetAdditionNeedsReference(diff.addedWikitext, diff.removedWikitext)) { + return true + } + return false +} + +async function thankableFlag( + revision: LatestRevision, + signal?: AbortSignal, +): Promise { + const goodFaith = await predictGoodFaith(revision.revid, signal) + if (goodFaith !== true) return 'none' + + const registered = !revision.anon && revision.userid > 0 + if (!registered || isTemporaryUser(revision.user)) return 'good-faith' + + const editCount = await fetchEditorEditCount(revision.user, signal) + if (editCount === 1) return 'first-edit' + if (editCount != null && editCount < NEW_EDITOR_MAX_EDITS) return 'new-editor' + return 'good-faith' +} + +async function classifyChange( + revision: LatestRevision, + title: string, + signal?: AbortSignal, +): Promise { + const diff = revision.parentid + ? await fetchRevisionDiff(revision.parentid, revision.revid, signal) + : undefined + + if (await needsReferenceFlag(revision, diff, signal)) return 'needs-reference' + + if (diff?.addedPlain.trim()) { + const tone = await predictTone( + title, + diff.removedPlain || diff.addedPlain, + diff.addedPlain, + signal, + ) + if (tone?.prediction && tone.probability >= TONE_THRESHOLD) return 'tone-issue' + } + + const risk = await predictRevertRisk(revision.revid, signal) + if (risk?.prediction && risk.probability >= REVERT_RISK_THRESHOLD) return 'high-revert-risk' + + const thankable = await thankableFlag(revision, signal) + if (thankable !== 'none') return thankable + + return 'none' +} + +/** Classify the latest edit on a saved page; optional pre-fetched revision skips the revision query. */ +export async function fetchRecentChangeForItem( + item: HomeSavedItem, + signal?: AbortSignal, + revision?: LatestRevision, + latestRevidByTitle?: Map, +): Promise { + if (!item.enwikiTitle) return null + + const latest = + revision ?? (await fetchLatestRevision(item.enwikiTitle, signal)) + if (!latest) return null + + const flag = await classifyChange(latest, item.enwikiTitle, signal) + const summary = formatEditSummaryDisplay(latest.parsedComment, latest.comment) + const wikiLatestRevid = latestRevidByTitle?.get(titleKey(item.enwikiTitle)) + const isLatest = revision + ? wikiLatestRevid != null && latest.revid === wikiLatestRevid + : true + + return { + enwikiTitle: item.enwikiTitle, + title: item.title, + editSummary: summary, + thumbnailUrl: item.thumbnailUrl, + diffUrl: diffUrl(item.enwikiTitle, latest.revid), + revid: latest.revid, + flag, + reverted: latest.reverted, + isLatest, + editedTimestamp: latest.timestamp, + editedLabel: formatEditMetaLabel(latest.timestamp, latest.user), + } +} + +/** Latest classified edit on each saved page with an enwiki article, newest first. */ +export async function fetchLatestRecentChanges( + items: HomeSavedItem[], + signal?: AbortSignal, +): Promise<{ + changes: HomeRecentChange[] + itemIdsWithoutRevisions: string[] + revisionLookupFailed: boolean +}> { + const candidates = items.filter( + (item): item is HomeSavedItem & { enwikiTitle: string } => Boolean(item.enwikiTitle), + ) + if (!candidates.length) { + return { changes: [], itemIdsWithoutRevisions: [], revisionLookupFailed: false } + } + + const titles = candidates.map((item) => item.enwikiTitle) + const { revisions: latestRevisions, lookupFailed: revisionLookupFailed } = + await fetchLatestRevisionsForTitles(titles, signal) + + const unresolved = candidates.filter( + (item) => !latestRevisions.has(titleKey(item.enwikiTitle)), + ) + if (unresolved.length) { + const fallback = await mapWithConcurrency( + unresolved, + 3, + async (item) => { + const revision = await fetchLatestRevision(item.enwikiTitle, signal) + if (!revision) return null + return { key: titleKey(item.enwikiTitle), revision } + }, + signal, + ) + for (const entry of fallback) { + if (entry) latestRevisions.set(entry.key, entry.revision) + } + } + + const latestRevidByTitle = new Map( + [...latestRevisions.entries()].map(([key, revision]) => [key, revision.revid]), + ) + + const itemIdsWithoutRevisions = candidates + .filter((item) => !latestRevisions.has(titleKey(item.enwikiTitle))) + .map((item) => item.id) + + const changes = await mapWithConcurrency( + candidates, + 3, + (item) => { + const revision = latestRevisions.get(titleKey(item.enwikiTitle)) + if (!revision) return Promise.resolve(null) + return fetchRecentChangeForItem(item, signal, revision, latestRevidByTitle).catch((err) => { + if ((err as Error).name === 'AbortError') throw err + return null + }) + }, + signal, + ) + + return { + changes: changes + .filter((change): change is HomeRecentChange => change !== null) + .sort((a, b) => b.editedTimestamp.localeCompare(a.editedTimestamp)), + itemIdsWithoutRevisions, + revisionLookupFailed, + } +} + +/** Latest classified edits for the home Activity preview (newest first, capped). */ +export async function fetchRecentChanges( + items: HomeSavedItem[], + signal?: AbortSignal, +): Promise { + const dependencyKey = bookmarksKey() + const cached = getCachedRecentChangesPreview(dependencyKey) + if (cached) return cached + + const result = (await fetchLatestRecentChanges(items, signal)).changes.slice( + 0, + MAX_RECENT_CHANGES, + ) + if (result.length) { + setCachedRecentChangesPreview(dependencyKey, result) + } + return result +} diff --git a/src/prototypes/musical-group/data/fetchRelatedReading.ts b/src/prototypes/musical-group/data/fetchRelatedReading.ts new file mode 100644 index 0000000..c5537b3 --- /dev/null +++ b/src/prototypes/musical-group/data/fetchRelatedReading.ts @@ -0,0 +1,158 @@ +import { wikimediaApiFetchHeaders } from '@/config' + +import { enwikiArticleUrl, normalizeEnwikiTitle, wikiActionUrl } from './enwikiTitle' +import { fetchWikimedia } from '@/lib/fetchWikimedia' +import { fetchPageSummary } from './pageSummary' +import type { HomeRelated, HomeSavedItem } from './types' +import { normalizeQid } from './wikidataApi' + +/** Minimum related cards shown on the personalized home tab. */ +const MIN_RELATED_COUNT = 3 + +interface SearchHit { + title?: string +} + +function pickRandom(items: T[], count: number): T[] { + const pool = [...items] + for (let i = pool.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)) + ;[pool[i], pool[j]] = [pool[j], pool[i]] + } + return pool.slice(0, count) +} + +async function fetchMorelikeHits( + seedTitle: string, + limit: number, + offset: number, + signal?: AbortSignal, +): Promise { + const url = wikiActionUrl({ + action: 'query', + list: 'search', + srsearch: `morelike:${seedTitle}`, + srwhat: 'text', + srnamespace: '0', + srlimit: String(limit), + sroffset: String(offset), + }) + + const response = await fetchWikimedia(url, { + signal, + headers: wikimediaApiFetchHeaders('musical-group-home-morelike'), + }) + if (!response.ok) return [] + + const json = (await response.json()) as { query?: { search?: SearchHit[] } } + return json.query?.search ?? [] +} + +/** Titles of pages similar to `seedTitle`, via a morelike search. */ +export async function fetchMorelikeTitles( + seedTitle: string, + signal?: AbortSignal, + limit = 20, + offset = 0, +): Promise { + const hits = await fetchMorelikeHits(seedTitle, limit, offset, signal) + return hits + .map((hit) => hit.title) + .filter((title): title is string => Boolean(title)) +} + +/** Resolve a single article title to a Related reading card, or null. */ +export async function resolveRelatedSummary( + title: string, + relatedToTitle: string, + signal?: AbortSignal, +): Promise { + const summary = await fetchPageSummary(title, signal, 'musical-group-home-related') + + // Reading cards open inside Wikita whenever the article has a Wikidata item. + const itemId = normalizeQid(summary?.wikibase_item) ?? undefined + + return { + title: summary?.normalizedtitle ?? summary?.title ?? title, + description: summary?.description ?? '', + thumbnailUrl: summary?.thumbnail?.source, + articleUrl: summary?.content_urls?.desktop?.page ?? enwikiArticleUrl(title), + itemId, + relatedToTitle, + } +} + +async function relatedForSeed( + seedTitle: string, + relatedToTitle: string, + excluded: Set, + signal?: AbortSignal, + maxCount = 1, +): Promise { + const titles = await fetchMorelikeTitles(seedTitle, signal, 8) + const results: HomeRelated[] = [] + + for (const title of titles) { + if (results.length >= maxCount) break + + const key = normalizeEnwikiTitle(title).toLowerCase() + if (excluded.has(key)) continue + + const result = await resolveRelatedSummary(title, relatedToTitle, signal) + if (!result) continue + + const resultKey = normalizeEnwikiTitle(result.title).toLowerCase() + if (excluded.has(resultKey)) continue + + excluded.add(resultKey) + results.push(result) + } + + return results +} + +/** Related articles (not already saved) from saved pages; empty unless at least three resolve. */ +export async function fetchRelatedReading( + items: HomeSavedItem[], + signal?: AbortSignal, +): Promise { + const candidates = items.filter((item) => item.enwikiTitle) + if (!candidates.length) return [] + + const excluded = new Set() + for (const item of items) { + if (item.enwikiTitle) excluded.add(normalizeEnwikiTitle(item.enwikiTitle).toLowerCase()) + } + + const primarySeeds = pickRandom(candidates, Math.min(candidates.length, MIN_RELATED_COUNT)) + const related: HomeRelated[] = [] + + // One recommendation per saved page first so the home preview mixes sources. + for (const seed of primarySeeds) { + const fromSeed = await relatedForSeed( + seed.enwikiTitle as string, + seed.title, + excluded, + signal, + 1, + ) + related.push(...fromSeed) + } + + if (related.length < MIN_RELATED_COUNT) { + for (const seed of pickRandom(candidates, candidates.length)) { + if (related.length >= MIN_RELATED_COUNT) break + + const fromSeed = await relatedForSeed( + seed.enwikiTitle as string, + seed.title, + excluded, + signal, + 1, + ) + related.push(...fromSeed) + } + } + + return related.length >= MIN_RELATED_COUNT ? related : [] +} diff --git a/src/prototypes/musical-group/data/fetchSavedItemSummaries.ts b/src/prototypes/musical-group/data/fetchSavedItemSummaries.ts new file mode 100644 index 0000000..dd4be25 --- /dev/null +++ b/src/prototypes/musical-group/data/fetchSavedItemSummaries.ts @@ -0,0 +1,94 @@ +import { mapWithConcurrency } from '@/lib/mapWithConcurrency' + +import type { BookmarkEntry } from './bookmarks' +import { bookmarksKey } from './cacheKeys' +import { sentenceCase } from './formatLabel' +import { + getCachedSavedSummaries, + setCachedSavedSummaries, +} from './homeTabCache' +import { getCachedMusicalGroup } from './musicalGroupCache' +import { fetchPageSummary } from './pageSummary' +import type { HomeSavedItem } from './types' +import { commonsFileUrl, fetchEntityClaims } from './wikidataApi' + +const RESOLVE_CONCURRENCY = 3 + +function savedSummariesNeedRefresh(items: HomeSavedItem[]): boolean { + return items.some((item) => !item.enwikiTitle) +} + +async function resolveSavedThumbnailUrl( + thumbnailUrl: string | undefined, + enwikiTitle: string | undefined, + signal?: AbortSignal, +): Promise { + if (thumbnailUrl || !enwikiTitle) return thumbnailUrl + + const summary = await fetchPageSummary(enwikiTitle, signal, 'musical-group-saved-summary') + return summary?.thumbnail?.source +} + +async function resolveSavedItem( + entry: BookmarkEntry, + signal?: AbortSignal, +): Promise { + const cached = getCachedMusicalGroup(entry.id) + if (cached) { + const { data } = cached + const thumbnailUrl = await resolveSavedThumbnailUrl( + data.images[0]?.url ?? + (data.imageFilename ? commonsFileUrl(data.imageFilename, 256) : undefined), + data.enwikiTitle, + signal, + ) + return { + id: entry.id, + title: data.label, + enwikiTitle: data.enwikiTitle, + description: data.description ? sentenceCase(data.description) : '', + thumbnailUrl, + savedAt: entry.savedAt, + } + } + + try { + const claims = await fetchEntityClaims(entry.id, signal) + const thumbnailUrl = await resolveSavedThumbnailUrl( + claims.imageFilename ? commonsFileUrl(claims.imageFilename, 256) : undefined, + claims.enwikiTitle, + signal, + ) + return { + id: entry.id, + title: claims.label, + enwikiTitle: claims.enwikiTitle, + description: claims.description ?? '', + thumbnailUrl, + savedAt: entry.savedAt, + } + } catch (err) { + if ((err as Error).name === 'AbortError') throw err + return null + } +} + +/** Resolve each bookmarked QID to display + lookup metadata (cache-first). */ +export async function fetchSavedItemSummaries( + entries: BookmarkEntry[], + signal?: AbortSignal, +): Promise { + const dependencyKey = bookmarksKey() + const cached = getCachedSavedSummaries(dependencyKey) + if (cached && !savedSummariesNeedRefresh(cached)) return cached + + const resolved = await mapWithConcurrency( + entries, + RESOLVE_CONCURRENCY, + (entry) => resolveSavedItem(entry, signal), + signal, + ) + const items = resolved.filter((item): item is HomeSavedItem => item !== null) + setCachedSavedSummaries(dependencyKey, items) + return items +} diff --git a/src/prototypes/musical-group/data/fetchTrending.ts b/src/prototypes/musical-group/data/fetchTrending.ts new file mode 100644 index 0000000..8ae7861 --- /dev/null +++ b/src/prototypes/musical-group/data/fetchTrending.ts @@ -0,0 +1,228 @@ +import { mapWithConcurrency } from '@/lib/mapWithConcurrency' + +import { utcDayKey } from './cacheKeys' +import { enwikiArticleUrl } from './enwikiTitle' +import { fetchEnwikiFeaturedFeedDay, wikimediaFeedErrorMessage } from './fetchEnwikiFeaturedFeedDay' +import { getCachedTrendingFeed, setCachedTrendingFeed } from './homeTabCache' +import { fetchPageSummary, type PageSummary } from './pageSummary' +import type { HomeTrending } from './types' +import { normalizeQid } from './wikidataApi' + +const MAX_TRENDING = 10 +const SUMMARY_CONCURRENCY = 3 + +interface MostreadArticle { + title?: string + views?: number + rank?: number +} + +let sessionCached: { day: string; value: HomeTrending[] } | null = null + +function parseMediaWikiTimestamp(timestamp: string): Date { + const trimmed = timestamp.trim() + if (!trimmed.length) return new Date(Number.NaN) + if (trimmed.includes('T')) { + return new Date(trimmed.endsWith('Z') ? trimmed : `${trimmed}Z`) + } + return new Date(trimmed.replace(' ', 'T') + 'Z') +} + +function formatRelativeTime(isoTimestamp: string): string { + const then = parseMediaWikiTimestamp(isoTimestamp).getTime() + if (Number.isNaN(then)) return '—' + const diffMs = Date.now() - then + if (diffMs < 0) return 'just now' + + const minutes = Math.floor(diffMs / (1000 * 60)) + const hours = Math.floor(diffMs / (1000 * 60 * 60)) + const days = Math.floor(diffMs / (1000 * 60 * 60 * 24)) + + if (minutes < 1) return 'just now' + if (minutes < 60) return minutes === 1 ? '1 minute ago' : `${minutes} minutes ago` + if (hours < 24) return hours === 1 ? '1 hour ago' : `${hours} hours ago` + if (days === 1) return '1 day ago' + if (days < 30) return `${days} days ago` + const months = Math.floor(days / 30) + if (months === 1) return '1 month ago' + return `${months} months ago` +} + +function formatViewCount(total: number): string { + if (total >= 1_000_000) return `${(total / 1_000_000).toFixed(1)}M` + if (total >= 1000) return `${(total / 1000).toFixed(1)}k` + return total.toLocaleString() +} + +function viewsPeriodLabel(mostreadDate?: string): string { + if (!mostreadDate) return 'today' + + const parsed = parseMediaWikiTimestamp(mostreadDate) + if (Number.isNaN(parsed.getTime())) return 'today' + + const yesterday = new Date() + yesterday.setUTCDate(yesterday.getUTCDate() - 1) + const isYesterday = + parsed.getUTCFullYear() === yesterday.getUTCFullYear() && + parsed.getUTCMonth() === yesterday.getUTCMonth() && + parsed.getUTCDate() === yesterday.getUTCDate() + if (isYesterday) return 'today' + + return `on ${parsed.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + timeZone: 'UTC', + })}` +} + +export function isTrendingSummaryIncomplete(item: HomeTrending): boolean { + return !item.lastEditedTimestamp +} + +function applySummaryToTrendingItem( + item: HomeTrending, + summary: PageSummary | null, +): HomeTrending { + if (!summary) return item + + const title = (summary.normalizedtitle ?? summary.title ?? item.enwikiTitle).replace(/_/g, ' ') + const timestamp = summary.timestamp ?? '' + + return { + ...item, + title, + description: summary.description ?? summary.extract ?? item.description, + thumbnailUrl: summary.thumbnail?.source ?? item.thumbnailUrl, + articleUrl: summary.content_urls?.desktop?.page ?? item.articleUrl, + itemId: normalizeQid(summary.wikibase_item) ?? item.itemId, + lastEditedTimestamp: timestamp, + lastEditedLabel: timestamp ? `Updated ${formatRelativeTime(timestamp)}` : item.lastEditedLabel, + } +} + +async function enrichMostreadArticle( + article: MostreadArticle, + viewsPeriod: string, + signal?: AbortSignal, +): Promise { + if (!article.title || article.views == null) return null + + const enwikiTitle = article.title.replace(/_/g, ' ') + const summary = await fetchPageSummary(enwikiTitle, signal, 'musical-group-trending') + const title = (summary?.normalizedtitle ?? summary?.title ?? enwikiTitle).replace(/_/g, ' ') + const timestamp = summary?.timestamp ?? '' + const viewCount = article.views + + return { + title, + enwikiTitle, + description: summary?.description ?? summary?.extract ?? '', + thumbnailUrl: summary?.thumbnail?.source, + articleUrl: summary?.content_urls?.desktop?.page ?? enwikiArticleUrl(enwikiTitle), + itemId: normalizeQid(summary?.wikibase_item) ?? undefined, + viewCount, + viewsLabel: `${formatViewCount(viewCount)} views ${viewsPeriod}`, + lastEditedTimestamp: timestamp, + lastEditedLabel: timestamp ? `Updated ${formatRelativeTime(timestamp)}` : 'Updated —', + rank: article.rank, + } +} + +function trendingItemsChanged(before: HomeTrending[], after: HomeTrending[]): boolean { + if (before.length !== after.length) return true + return after.some( + (item, index) => + item.lastEditedTimestamp !== before[index]?.lastEditedTimestamp || + item.description !== before[index]?.description || + item.thumbnailUrl !== before[index]?.thumbnailUrl || + item.itemId !== before[index]?.itemId, + ) +} + +/** Re-fetch summaries for trending cards that failed enrichment earlier. */ +export async function refillIncompleteTrendingItems( + items: HomeTrending[], + signal?: AbortSignal, +): Promise { + const incomplete = items.filter(isTrendingSummaryIncomplete) + if (!incomplete.length) return items + + const byTitle = new Map(items.map((item) => [item.enwikiTitle.toLowerCase(), item])) + + for (const item of incomplete) { + if (signal?.aborted) break + + const summary = await fetchPageSummary(item.enwikiTitle, signal, 'musical-group-trending', { + bypassFailureCache: true, + }) + byTitle.set(item.enwikiTitle.toLowerCase(), applySummaryToTrendingItem(item, summary)) + } + + return items.map((item) => byTitle.get(item.enwikiTitle.toLowerCase()) ?? item) +} + +async function persistTrendingIfChanged( + dayKey: string, + before: HomeTrending[], + after: HomeTrending[], +): Promise { + if (after.length) { + sessionCached = { day: dayKey, value: after } + if (trendingItemsChanged(before, after)) { + setCachedTrendingFeed(dayKey, after) + } + } + return after +} + +export function clearTrendingSessionCache(): void { + sessionCached = null +} + +/** Most-read articles from today's featured feed, enriched with page summaries. */ +export async function fetchTrendingFeed(signal?: AbortSignal): Promise { + const dayKey = utcDayKey() + + const stored = getCachedTrendingFeed(dayKey) + if (stored?.length) { + const refilled = await refillIncompleteTrendingItems(stored, signal) + return persistTrendingIfChanged(dayKey, stored, refilled) + } + + if (sessionCached && sessionCached.day === dayKey && sessionCached.value.length) { + const refilled = await refillIncompleteTrendingItems(sessionCached.value, signal) + return persistTrendingIfChanged(dayKey, sessionCached.value, refilled) + } + + const { ok, json, status } = await fetchEnwikiFeaturedFeedDay(signal, 'musical-group-trending-feed') + if (!ok) { + throw new Error(wikimediaFeedErrorMessage(status, 'Trending articles')) + } + + const articles = json?.mostread?.articles + if (!articles?.length) return [] + + const viewsPeriod = viewsPeriodLabel(json.mostread?.date) + const slice = [...articles] + .sort((a, b) => (a.rank ?? Infinity) - (b.rank ?? Infinity)) + .slice(0, MAX_TRENDING) + + const [firstArticle, ...restArticles] = slice + const firstEnriched = firstArticle + ? await enrichMostreadArticle(firstArticle, viewsPeriod, signal) + : null + const restEnriched = restArticles.length + ? await mapWithConcurrency( + restArticles, + SUMMARY_CONCURRENCY, + (article) => enrichMostreadArticle(article, viewsPeriod, signal), + signal, + ) + : [] + + const enriched = [firstEnriched, ...restEnriched].filter( + (item): item is HomeTrending => item !== null, + ) + const refilled = await refillIncompleteTrendingItems(enriched, signal) + return persistTrendingIfChanged(dayKey, enriched, refilled) +} diff --git a/src/prototypes/musical-group/data/fetchWikitaArticle.ts b/src/prototypes/musical-group/data/fetchWikitaArticle.ts new file mode 100644 index 0000000..ff85949 --- /dev/null +++ b/src/prototypes/musical-group/data/fetchWikitaArticle.ts @@ -0,0 +1,84 @@ +import { wikimediaApiFetchHeaders } from '@/config' +import { fetchWikimedia } from '@/lib/fetchWikimedia' + +import { EN_WIKI_HOST, normalizeEnwikiTitle } from './enwikiTitle' +import { + getCachedMusicalGroup, + setCachedMusicalGroupArticleHtml, +} from './musicalGroupCache' + +export interface FetchWikitaArticleOptions { + signal?: AbortSignal + itemId?: string +} + +const articleHtmlMemory = new Map() +const inFlightFetches = new Map>() + +function extractParserOutput(raw: string): string { + const bodyMatch = raw.match(/]*>([\s\S]*?)<\/body>/i) + if (bodyMatch) return bodyMatch[1] + return raw +} + +function cacheKey(title: string): string { + return normalizeEnwikiTitle(title).toLowerCase() +} + +export function clearWikitaArticleMemoryCache(): void { + articleHtmlMemory.clear() + inFlightFetches.clear() +} + +export async function fetchWikitaArticleHtml( + title: string, + options: FetchWikitaArticleOptions = {}, +): Promise { + const normalized = normalizeEnwikiTitle(title) + if (!normalized) { + throw new Error('Missing article title') + } + + const key = cacheKey(normalized) + + if (options.itemId) { + const entityCached = getCachedMusicalGroup(options.itemId) + if (entityCached?.articleHtml) { + articleHtmlMemory.set(key, entityCached.articleHtml) + return entityCached.articleHtml + } + } + + const cached = articleHtmlMemory.get(key) + if (cached) return cached + + let bodyPromise = inFlightFetches.get(key) + if (!bodyPromise) { + bodyPromise = (async () => { + const slug = encodeURIComponent(normalized.replace(/ /g, '_')) + const url = `https://${EN_WIKI_HOST}/api/rest_v1/page/html/${slug}` + const response = await fetchWikimedia(url, { + signal: options.signal, + headers: { + Accept: 'text/html; charset=utf-8', + ...wikimediaApiFetchHeaders('musical-group-article-html'), + }, + }) + if (!response.ok) { + throw new Error(`HTTP ${response.status}`) + } + const text = await response.text() + const html = extractParserOutput(text) + articleHtmlMemory.set(key, html) + if (options.itemId) { + setCachedMusicalGroupArticleHtml(options.itemId, html) + } + return html + })().finally(() => { + inFlightFetches.delete(key) + }) + inFlightFetches.set(key, bodyPromise) + } + + return bodyPromise +} diff --git a/src/prototypes/musical-group/data/fetchWithTimeout.ts b/src/prototypes/musical-group/data/fetchWithTimeout.ts new file mode 100644 index 0000000..ed74f7f --- /dev/null +++ b/src/prototypes/musical-group/data/fetchWithTimeout.ts @@ -0,0 +1 @@ +export { fetchWithTimeout, type FetchWithTimeoutInit } from '@/lib/fetchWithTimeout' diff --git a/src/prototypes/musical-group/data/formatLabel.ts b/src/prototypes/musical-group/data/formatLabel.ts new file mode 100644 index 0000000..e3751c2 --- /dev/null +++ b/src/prototypes/musical-group/data/formatLabel.ts @@ -0,0 +1,23 @@ +/** Capitalize the first character for reader-facing labels. */ +export function sentenceCase(text: string): string { + const trimmed = text.trim() + if (!trimmed.length) return trimmed + return trimmed.charAt(0).toUpperCase() + trimmed.slice(1) +} + +/** Sentence-case a comma-separated list (first item only, per design). */ +export function sentenceCaseList(items: string[]): string { + const joined = items.join(', ') + return sentenceCase(joined) +} + +/** MediaWiki page title → reader-facing string (underscores → spaces). */ +export function formatWikiTitle(title: string): string { + return title.replace(/_/g, ' ').trim() +} + +/** Prefer English Wikipedia article title over Wikidata label for display. */ +export function entityDisplayLabel(wikidataLabel: string, enwikiTitle?: string): string { + if (enwikiTitle) return formatWikiTitle(enwikiTitle) + return wikidataLabel +} diff --git a/src/prototypes/musical-group/data/headerVariantPreference.ts b/src/prototypes/musical-group/data/headerVariantPreference.ts new file mode 100644 index 0000000..6b44372 --- /dev/null +++ b/src/prototypes/musical-group/data/headerVariantPreference.ts @@ -0,0 +1,101 @@ +export type WikitaChromeHeaderVariant = + | 'black' + | 'off-black' + | 'gray' + | 'gray-bold' + | 'red-light' + | 'red-dark' + | 'orange-light' + | 'orange-dark' + | 'brown-light' + | 'orange-bold' + | 'yellow-light' + | 'yellow-bold' + | 'lime-light' + | 'lime-bold' + | 'green-light' + | 'green-dark' + | 'blue-light' + | 'blue-bold' + | 'purple-light' + | 'purple-bold' + | 'pink-light' + | 'pink-bold' + | 'maroon-light' + | 'maroon-bold' + +export const DEFAULT_HEADER_VARIANT: WikitaChromeHeaderVariant = 'gray' + +export const WIKITA_CHROME_HEADER_VARIANT_MENU_ITEMS: { + value: WikitaChromeHeaderVariant + label: string +}[] = [ + { value: 'black', label: 'Black' }, + { value: 'off-black', label: 'Off black' }, + { value: 'gray', label: 'Gray' }, + { value: 'gray-bold', label: 'Gray bold' }, + { value: 'red-light', label: 'Red light' }, + { value: 'red-dark', label: 'Red' }, + { value: 'orange-light', label: 'Orange light' }, + { value: 'orange-dark', label: 'Brown' }, + { value: 'brown-light', label: 'Brown light' }, + { value: 'orange-bold', label: 'Orange bold' }, + { value: 'yellow-light', label: 'Yellow light' }, + { value: 'yellow-bold', label: 'Yellow bold' }, + { value: 'lime-light', label: 'Lime light' }, + { value: 'lime-bold', label: 'Lime bold' }, + { value: 'green-light', label: 'Green light' }, + { value: 'green-dark', label: 'Green dark' }, + { value: 'blue-light', label: 'Blue light' }, + { value: 'blue-bold', label: 'Blue bold' }, + { value: 'purple-light', label: 'Purple light' }, + { value: 'purple-bold', label: 'Purple bold' }, + { value: 'pink-light', label: 'Pink light' }, + { value: 'pink-bold', label: 'Pink bold' }, + { value: 'maroon-light', label: 'Maroon light' }, + { value: 'maroon-bold', label: 'Maroon bold' }, +] + +const LEGACY_VARIANT_ALIASES: Record = { + 'yellow-dark': 'yellow-bold', + 'lime-dark': 'lime-bold', + 'blue-dark': 'blue-bold', + 'purple-dark': 'purple-bold', + 'pink-dark': 'pink-bold', + 'maroon-dark': 'maroon-bold', +} + +const VALID_VARIANTS = new Set( + WIKITA_CHROME_HEADER_VARIANT_MENU_ITEMS.map((item) => item.value), +) + +const STORAGE_KEY = 'musical-group-header-variant' + +function resolveHeaderVariant(value: string): WikitaChromeHeaderVariant | null { + if (VALID_VARIANTS.has(value as WikitaChromeHeaderVariant)) { + return value as WikitaChromeHeaderVariant + } + return LEGACY_VARIANT_ALIASES[value] ?? null +} + +export function loadHeaderVariantPreference(): WikitaChromeHeaderVariant { + if (typeof window === 'undefined') return DEFAULT_HEADER_VARIANT + + try { + const stored = window.localStorage.getItem(STORAGE_KEY) + if (!stored) return DEFAULT_HEADER_VARIANT + return resolveHeaderVariant(stored) ?? DEFAULT_HEADER_VARIANT + } catch { + return DEFAULT_HEADER_VARIANT + } +} + +export function saveHeaderVariantPreference(variant: WikitaChromeHeaderVariant): void { + if (typeof window === 'undefined') return + + try { + window.localStorage.setItem(STORAGE_KEY, variant) + } catch { + // Quota or private-mode failures — ignore. + } +} diff --git a/src/prototypes/musical-group/data/homeTabCache.ts b/src/prototypes/musical-group/data/homeTabCache.ts new file mode 100644 index 0000000..2fc27cc --- /dev/null +++ b/src/prototypes/musical-group/data/homeTabCache.ts @@ -0,0 +1,255 @@ +import type { + HomeFeaturedTab, + HomeHelpWanted, + HomeRecentChange, + HomeRelated, + HomeSavedItem, + HomeTrending, +} from './types' +import { readVersionedStore, setVersionedEntry, writeVersionedStore } from './wikitaCache' + +const STORAGE_KEY = 'musical-group-home-cache' +const CACHE_VERSION = 1 + +export type RelatedFeedTabId = 'home' | 'read' | 'saved' + +export interface RelatedFeedSeedCursor { + searchTitle: string + displayTitle: string + offset: number +} + +export interface RelatedFeedPoolTitle { + title: string + relatedToTitle: string +} + +export interface CachedRelatedFeedState { + dependencyKey: string + items: HomeRelated[] + seen: string[] + seedTitles: string[] + seeds: RelatedFeedSeedCursor[] + titlePool: RelatedFeedPoolTitle[] + nextSeedIndex: number + hasMore: boolean + fetchedAt: number +} + +export interface CachedActivityFeedState { + dependencyKey: string + changes: HomeRecentChange[] + seenRevids: number[] + pageStates: { + itemId: string + itemTitle: string + enwikiTitle: string + thumbnailUrl?: string + savedAt: number + oldestRevid?: number + exhausted: boolean + }[] + latestRevidByTitle: [string, number][] + queue: { + itemId: string + itemTitle: string + enwikiTitle: string + thumbnailUrl?: string + savedAt: number + revision: { + revid: number + parentid: number + user: string + userid: number + comment: string + parsedComment: string + anon: boolean + timestamp: string + reverted: boolean + } + }[] + hasMore: boolean + fetchedAt: number +} + +export interface CachedContributeFeedState { + dependencyKey: string + savedSuggestions: HomeHelpWanted[] + relatedSuggestions: HomeHelpWanted[] + seenTitles: string[] + excludedIds: string[] + seedTitles: string[] + seeds: RelatedFeedSeedCursor[] + titlePool: RelatedFeedPoolTitle[] + nextSeedIndex: number + relatedHasMore: boolean + fetchedAt: number +} + +interface CachedFeaturedEntry { + dependencyKey: string + data: HomeFeaturedTab + fetchedAt: number +} + +interface CachedTrendingEntry { + dependencyKey: string + data: HomeTrending[] + fetchedAt: number +} + +interface CachedSavedSummariesEntry { + dependencyKey: string + data: HomeSavedItem[] + fetchedAt: number +} + +interface CachedHelpWantedEntry { + dependencyKey: string + data: HomeHelpWanted[] + fetchedAt: number +} + +interface CachedRecentChangesEntry { + dependencyKey: string + data: HomeRecentChange[] + fetchedAt: number +} + +type HomeCacheEntry = + | CachedFeaturedEntry + | CachedTrendingEntry + | CachedSavedSummariesEntry + | CachedHelpWantedEntry + | CachedRecentChangesEntry + | CachedRelatedFeedState + | CachedActivityFeedState + | CachedContributeFeedState + +function isHomeCacheEntry(entry: unknown): entry is HomeCacheEntry { + return typeof entry === 'object' && entry !== null && typeof (entry as HomeCacheEntry).fetchedAt === 'number' +} + +function readEntries(): Record { + return readVersionedStore(STORAGE_KEY, CACHE_VERSION, isHomeCacheEntry) +} + +function writeEntries(entries: Record): void { + writeVersionedStore(STORAGE_KEY, CACHE_VERSION, entries) +} + +function getEntry(key: string, dependencyKey: string): T | null { + const entry = readEntries()[key] + if (!entry || entry.dependencyKey !== dependencyKey) return null + return entry as T +} + +function setEntry(key: string, entry: HomeCacheEntry): void { + setVersionedEntry(STORAGE_KEY, CACHE_VERSION, key, entry, isHomeCacheEntry) +} + +export function getCachedFeaturedTab(dependencyKey: string): HomeFeaturedTab | null { + return getEntry('featured', dependencyKey)?.data ?? null +} + +export function setCachedFeaturedTab(dependencyKey: string, data: HomeFeaturedTab): void { + setEntry('featured', { dependencyKey, data, fetchedAt: Date.now() }) +} + +export function clearCachedFeaturedTab(dependencyKey: string): void { + const entries = readEntries() + if (entries.featured?.dependencyKey !== dependencyKey) return + delete entries.featured + writeEntries(entries) +} + +export function getCachedTrendingFeed(dependencyKey: string): HomeTrending[] | null { + return getEntry('trending', dependencyKey)?.data ?? null +} + +export function setCachedTrendingFeed(dependencyKey: string, data: HomeTrending[]): void { + setEntry('trending', { dependencyKey, data, fetchedAt: Date.now() }) +} + +export function clearCachedTrendingFeed(dependencyKey: string): void { + const entries = readEntries() + if (entries.trending?.dependencyKey !== dependencyKey) return + delete entries.trending + writeEntries(entries) +} + +export function getCachedSavedSummaries(dependencyKey: string): HomeSavedItem[] | null { + return getEntry('savedSummaries', dependencyKey)?.data ?? null +} + +export function setCachedSavedSummaries(dependencyKey: string, data: HomeSavedItem[]): void { + setEntry('savedSummaries', { dependencyKey, data, fetchedAt: Date.now() }) +} + +export function getCachedHelpWanted(dependencyKey: string): HomeHelpWanted[] | null { + const data = getEntry('helpWanted', dependencyKey)?.data + if (!data?.length) return null + return data +} + +export function setCachedHelpWanted(dependencyKey: string, data: HomeHelpWanted[]): void { + setEntry('helpWanted', { dependencyKey, data, fetchedAt: Date.now() }) +} + +export function getCachedRecentChangesPreview(dependencyKey: string): HomeRecentChange[] | null { + const data = getEntry('recentChanges', dependencyKey)?.data + if (!data?.length) return null + return data +} + +export function setCachedRecentChangesPreview(dependencyKey: string, data: HomeRecentChange[]): void { + setEntry('recentChanges', { dependencyKey, data, fetchedAt: Date.now() }) +} + +export function relatedFeedCacheKey(tab: RelatedFeedTabId, dependencyKey: string): string { + return `related:${tab}:${dependencyKey}` +} + +export function getCachedRelatedFeed( + tab: RelatedFeedTabId, + dependencyKey: string, +): CachedRelatedFeedState | null { + return getEntry(relatedFeedCacheKey(tab, dependencyKey), dependencyKey) +} + +export function setCachedRelatedFeed(tab: RelatedFeedTabId, state: CachedRelatedFeedState): void { + setEntry(relatedFeedCacheKey(tab, state.dependencyKey), state) +} + +export function activityFeedCacheKey(savedKey: string): string { + return `activity:${savedKey}` +} + +export function contributeFeedCacheKey(savedKey: string): string { + return `contribute:${savedKey}` +} + +export function getCachedActivityFeed(savedKey: string): CachedActivityFeedState | null { + return getEntry(activityFeedCacheKey(savedKey), savedKey) +} + +export function setCachedActivityFeed(state: CachedActivityFeedState): void { + setEntry(activityFeedCacheKey(state.dependencyKey), state) +} + +export function getCachedContributeFeed(savedKey: string): CachedContributeFeedState | null { + return getEntry(contributeFeedCacheKey(savedKey), savedKey) +} + +export function setCachedContributeFeed(state: CachedContributeFeedState): void { + setEntry(contributeFeedCacheKey(state.dependencyKey), state) +} + +export function clearHomeTabCache(): void { + if (typeof window === 'undefined') return + try { + window.localStorage.removeItem(STORAGE_KEY) + } catch { + // Ignore. + } +} diff --git a/src/prototypes/musical-group/data/imagesTabPreference.ts b/src/prototypes/musical-group/data/imagesTabPreference.ts new file mode 100644 index 0000000..dc58689 --- /dev/null +++ b/src/prototypes/musical-group/data/imagesTabPreference.ts @@ -0,0 +1,21 @@ +const STORAGE_KEY = 'musical-group-images-tab-opened' + +export function loadImagesTabOpenedPreference(): boolean { + if (typeof window === 'undefined') return false + + try { + return window.localStorage.getItem(STORAGE_KEY) === '1' + } catch { + return false + } +} + +export function saveImagesTabOpenedPreference(): void { + if (typeof window === 'undefined') return + + try { + window.localStorage.setItem(STORAGE_KEY, '1') + } catch { + // Quota or private-mode failures — ignore. + } +} diff --git a/src/prototypes/musical-group/data/liftWing.ts b/src/prototypes/musical-group/data/liftWing.ts new file mode 100644 index 0000000..1f9688f --- /dev/null +++ b/src/prototypes/musical-group/data/liftWing.ts @@ -0,0 +1,227 @@ +import { wikimediaApiFetchHeaders } from '@/config' + +import { fetchWithTimeout } from './fetchWithTimeout' +import { + getCachedGoodFaith, + getCachedReferenceNeed, + getCachedRevertRisk, + setCachedGoodFaith, + setCachedReferenceNeed, + setCachedRevertRisk, +} from './liftWingCache' + +const LIFT_WING_BASE = 'https://api.wikimedia.org/service/lw/inference/v1/models' + +/** Session layer on top of localStorage. */ +const goodFaithMemory = new Map() +const revertRiskMemory = new Map() +const referenceNeedMemory = new Map() + +export interface RevertRiskResult { + prediction: boolean + probability: number +} + +export interface ToneResult { + prediction: boolean + probability: number +} + +export { clearLiftWingCache } from './liftWingCache' + +function liftWingHeaders(purpose: string): HeadersInit { + return { + 'Content-Type': 'application/json', + ...wikimediaApiFetchHeaders(purpose), + } +} + +/** enwiki good-faith prediction for a revision. */ +export async function predictGoodFaith( + revId: number, + signal?: AbortSignal, +): Promise { + if (goodFaithMemory.has(revId)) return goodFaithMemory.get(revId) + + const stored = getCachedGoodFaith(revId) + if (stored !== undefined) { + const value = stored === null ? undefined : stored + goodFaithMemory.set(revId, value) + return value + } + + try { + const response = await fetchWithTimeout(`${LIFT_WING_BASE}/enwiki-goodfaith:predict`, { + method: 'POST', + signal, + headers: liftWingHeaders('musical-group-goodfaith'), + body: JSON.stringify({ rev_id: revId }), + }) + if (!response.ok) { + goodFaithMemory.set(revId, undefined) + setCachedGoodFaith(revId, null) + return undefined + } + + const json = (await response.json()) as { + enwiki?: { + scores?: Record + } + } + const prediction = json.enwiki?.scores?.[String(revId)]?.goodfaith?.score?.prediction + const value = typeof prediction === 'boolean' ? prediction : undefined + goodFaithMemory.set(revId, value) + setCachedGoodFaith(revId, value ?? null) + return value + } catch (err) { + if ((err as Error).name === 'AbortError') throw err + goodFaithMemory.set(revId, undefined) + setCachedGoodFaith(revId, null) + return undefined + } +} + +/** Reference-need score for a revision (0–1; higher = more citation follow-up needed). */ +export async function predictReferenceNeed( + revId: number, + lang = 'en', + signal?: AbortSignal, +): Promise { + if (referenceNeedMemory.has(revId)) return referenceNeedMemory.get(revId) + + const stored = getCachedReferenceNeed(revId) + if (stored !== undefined) { + const value = stored === null ? undefined : stored + referenceNeedMemory.set(revId, value) + return value + } + + try { + const response = await fetchWithTimeout(`${LIFT_WING_BASE}/reference-need:predict`, { + method: 'POST', + signal, + headers: liftWingHeaders('musical-group-reference-need'), + body: JSON.stringify({ rev_id: revId, lang }), + }) + if (!response.ok) { + referenceNeedMemory.set(revId, undefined) + setCachedReferenceNeed(revId, null) + return undefined + } + + const json = (await response.json()) as { reference_need_score?: number } + const score = json.reference_need_score + const value = typeof score === 'number' ? score : undefined + referenceNeedMemory.set(revId, value) + setCachedReferenceNeed(revId, value ?? null) + return value + } catch (err) { + if ((err as Error).name === 'AbortError') throw err + referenceNeedMemory.set(revId, undefined) + setCachedReferenceNeed(revId, null) + return undefined + } +} + +/** Language-agnostic revert-risk prediction for a revision. */ +export async function predictRevertRisk( + revId: number, + signal?: AbortSignal, +): Promise { + if (revertRiskMemory.has(revId)) return revertRiskMemory.get(revId) + + const stored = getCachedRevertRisk(revId) + if (stored !== undefined) { + const value = stored === null ? undefined : stored + revertRiskMemory.set(revId, value) + return value + } + + try { + const response = await fetchWithTimeout( + `${LIFT_WING_BASE}/revertrisk-language-agnostic:predict`, + { + method: 'POST', + signal, + headers: liftWingHeaders('musical-group-revertrisk'), + body: JSON.stringify({ rev_id: revId, lang: 'en' }), + }, + ) + if (!response.ok) { + revertRiskMemory.set(revId, undefined) + setCachedRevertRisk(revId, null) + return undefined + } + + const json = (await response.json()) as { + output?: { prediction?: boolean; probabilities?: { true?: number } } + } + const prediction = json.output?.prediction + if (typeof prediction !== 'boolean') { + revertRiskMemory.set(revId, undefined) + setCachedRevertRisk(revId, null) + return undefined + } + const result: RevertRiskResult = { + prediction, + probability: json.output?.probabilities?.true ?? (prediction ? 1 : 0), + } + revertRiskMemory.set(revId, result) + setCachedRevertRisk(revId, result) + return result + } catch (err) { + if ((err as Error).name === 'AbortError') throw err + revertRiskMemory.set(revId, undefined) + setCachedRevertRisk(revId, null) + return undefined + } +} + +/** Edit Check tone prediction for a before/after text pair. */ +export async function predictTone( + pageTitle: string, + originalText: string, + modifiedText: string, + signal?: AbortSignal, +): Promise { + if (!modifiedText.trim()) return undefined + + try { + const response = await fetchWithTimeout(`${LIFT_WING_BASE}/edit-check:predict`, { + method: 'POST', + signal, + headers: liftWingHeaders('musical-group-tone'), + body: JSON.stringify({ + instances: [ + { + lang: 'en', + check_type: 'tone', + page_title: pageTitle, + original_text: originalText, + modified_text: modifiedText, + }, + ], + }), + }) + if (!response.ok) return undefined + + const json = (await response.json()) as { + predictions?: { prediction?: boolean; probability?: number }[] + } + const prediction = json.predictions?.[0] + if (!prediction || typeof prediction.prediction !== 'boolean') return undefined + return { + prediction: prediction.prediction, + probability: prediction.probability ?? 0, + } + } catch (err) { + if ((err as Error).name === 'AbortError') throw err + return undefined + } +} + +export function clearLiftWingMemoryCache(): void { + goodFaithMemory.clear() + revertRiskMemory.clear() + referenceNeedMemory.clear() +} diff --git a/src/prototypes/musical-group/data/liftWingCache.ts b/src/prototypes/musical-group/data/liftWingCache.ts new file mode 100644 index 0000000..1195fb3 --- /dev/null +++ b/src/prototypes/musical-group/data/liftWingCache.ts @@ -0,0 +1,104 @@ +const STORAGE_KEY = 'musical-group-liftwing-cache' +const CACHE_VERSION = 1 + +interface LiftWingCachePayload { + version: number + goodFaith: Record + referenceNeed: Record + revertRisk: Record +} + +const memoryStore: LiftWingCachePayload = { + version: CACHE_VERSION, + goodFaith: {}, + referenceNeed: {}, + revertRisk: {}, +} + +function readPayload(): LiftWingCachePayload { + if (typeof window === 'undefined') return memoryStore + + try { + const raw = window.localStorage.getItem(STORAGE_KEY) + if (!raw) return memoryStore + + const parsed = JSON.parse(raw) as LiftWingCachePayload + if (parsed.version !== CACHE_VERSION) return memoryStore + + memoryStore.goodFaith = parsed.goodFaith ?? {} + memoryStore.referenceNeed = parsed.referenceNeed ?? {} + memoryStore.revertRisk = parsed.revertRisk ?? {} + return memoryStore + } catch { + return memoryStore + } +} + +function persistPayload(): void { + if (typeof window === 'undefined') return + + try { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(memoryStore)) + } catch { + // Ignore quota failures. + } +} + +export function getCachedGoodFaith(revId: number): boolean | null | undefined { + const store = readPayload() + if (!Object.prototype.hasOwnProperty.call(store.goodFaith, String(revId))) { + return undefined + } + return store.goodFaith[String(revId)] +} + +export function setCachedGoodFaith(revId: number, value: boolean | null): void { + readPayload() + memoryStore.goodFaith[String(revId)] = value + persistPayload() +} + +export function getCachedReferenceNeed(revId: number): number | null | undefined { + const store = readPayload() + if (!Object.prototype.hasOwnProperty.call(store.referenceNeed, String(revId))) { + return undefined + } + return store.referenceNeed[String(revId)] +} + +export function setCachedReferenceNeed(revId: number, value: number | null): void { + readPayload() + memoryStore.referenceNeed[String(revId)] = value + persistPayload() +} + +export function getCachedRevertRisk( + revId: number, +): { prediction: boolean; probability: number } | null | undefined { + const store = readPayload() + if (!Object.prototype.hasOwnProperty.call(store.revertRisk, String(revId))) { + return undefined + } + return store.revertRisk[String(revId)] +} + +export function setCachedRevertRisk( + revId: number, + value: { prediction: boolean; probability: number } | null, +): void { + readPayload() + memoryStore.revertRisk[String(revId)] = value + persistPayload() +} + +export function clearLiftWingCache(): void { + memoryStore.goodFaith = {} + memoryStore.referenceNeed = {} + memoryStore.revertRisk = {} + if (typeof window === 'undefined') return + try { + window.localStorage.removeItem(STORAGE_KEY) + } catch { + // Ignore. + } +} diff --git a/src/prototypes/musical-group/data/loadMusicalGroup.ts b/src/prototypes/musical-group/data/loadMusicalGroup.ts new file mode 100644 index 0000000..9997b25 --- /dev/null +++ b/src/prototypes/musical-group/data/loadMusicalGroup.ts @@ -0,0 +1,22 @@ +import { fetchMusicalGroup } from './fetchMusicalGroup' +import { getCachedMusicalGroup, setCachedMusicalGroup } from './musicalGroupCache' +import type { FetchMusicalGroupOptions, MusicalGroupData } from './types' + +export interface LoadMusicalGroupResult { + data: MusicalGroupData + fromCache: boolean +} + +export async function loadMusicalGroup( + id: string, + options: FetchMusicalGroupOptions = {}, +): Promise { + const cached = getCachedMusicalGroup(id) + if (cached) { + return { data: cached.data, fromCache: true } + } + + const { data } = await fetchMusicalGroup(id, options) + setCachedMusicalGroup(id, data) + return { data, fromCache: false } +} diff --git a/src/prototypes/musical-group/data/loadMusicalGroupOverview.ts b/src/prototypes/musical-group/data/loadMusicalGroupOverview.ts new file mode 100644 index 0000000..67c0410 --- /dev/null +++ b/src/prototypes/musical-group/data/loadMusicalGroupOverview.ts @@ -0,0 +1,79 @@ +import { formatCommonsPhotosLabel, getCommonsCategoryCount } from './commonsImages' +import type { CommonsCategoryCount } from './commonsImages' +import { fetchMusicalGroupOverview } from './fetchMusicalGroupOverview' +import { + getCachedMusicalGroup, + setCachedMusicalGroupOverview, +} from './musicalGroupCache' +import type { MusicalGroupData, MusicalGroupOverviewData } from './types' + +export interface LoadMusicalGroupOverviewOptions { + signal?: AbortSignal + onPartial?: (overview: MusicalGroupOverviewData) => void +} + +export interface LoadMusicalGroupOverviewResult { + overview: MusicalGroupOverviewData + fromCache: boolean +} + +async function resolveCommonsCategoryCount( + data: MusicalGroupData, + signal?: AbortSignal, +): Promise { + const category = data.commonsCategory?.trim() + if (!category) return undefined + return getCommonsCategoryCount(category, signal) +} + +function buildImagesOverview( + data: MusicalGroupData, + info?: CommonsCategoryCount, +): MusicalGroupOverviewData['images'] | undefined { + // Overview Images card is for Wikidata entities with a Commons category (P373), + // not enwiki-only articles where label fallback would invent a category name. + if (!data.commonsCategory?.trim() || !info) return undefined + // Nothing known (empty/missing category or a failed lookup) — show no count. + if (info.files === 0 && info.subcats === 0) return undefined + return { + itemCount: info.subcats > 0 ? info.subcats : info.files, + itemCountLabel: formatCommonsPhotosLabel(info.files, info.subcats), + } +} + +export function isCachedOverviewUsable(overview: MusicalGroupOverviewData): boolean { + const views = overview.article?.viewCount ?? 0 + const label = overview.article?.viewsLabel ?? '' + if (views > 0) return true + // Stale entries from failed wikimedia.org metrics fetches (CORS / "0 views …"). + return !label.startsWith('0 views') && label !== '—' +} + +export async function loadMusicalGroupOverview( + id: string, + data: MusicalGroupData, + options: LoadMusicalGroupOverviewOptions = {}, +): Promise { + const cached = getCachedMusicalGroup(id) + if (cached?.overview && isCachedOverviewUsable(cached.overview)) { + return { overview: cached.overview, fromCache: true } + } + + const { signal, onPartial } = options + + const categoryPromise = resolveCommonsCategoryCount(data, signal).catch(() => undefined) + + const overview = await fetchMusicalGroupOverview(data, { + signal, + onPartial, + }) + + const categoryInfo = await categoryPromise + overview.images = buildImagesOverview(data, categoryInfo) + if (overview.images) { + onPartial?.(overview) + } + + setCachedMusicalGroupOverview(id, overview) + return { overview, fromCache: false } +} diff --git a/src/prototypes/musical-group/data/mergeExternalLinks.ts b/src/prototypes/musical-group/data/mergeExternalLinks.ts new file mode 100644 index 0000000..785299d --- /dev/null +++ b/src/prototypes/musical-group/data/mergeExternalLinks.ts @@ -0,0 +1,15 @@ +export const OFFICIAL_WEBSITE_LABEL = 'Official website' + +export function normalizeUrlForDedup(url: string): string { + try { + const parsed = new URL(url) + parsed.hash = '' + parsed.hostname = parsed.hostname.toLowerCase() + if (parsed.pathname.endsWith('/') && parsed.pathname.length > 1) { + parsed.pathname = parsed.pathname.replace(/\/+$/, '') + } + return parsed.toString() + } catch { + return url.trim().toLowerCase() + } +} diff --git a/src/prototypes/musical-group/data/musicalGroupCache.ts b/src/prototypes/musical-group/data/musicalGroupCache.ts new file mode 100644 index 0000000..ee9c820 --- /dev/null +++ b/src/prototypes/musical-group/data/musicalGroupCache.ts @@ -0,0 +1,467 @@ +import { normalizeQid } from './wikidataApi' +import type { + CarouselImage, + EditIndicator, + HomeRecentChange, + HomeRecentChangeFlag, + MusicalGroupData, + MusicalGroupInfobox, + MusicalGroupOverviewArticle, + MusicalGroupOverviewData, + MusicalGroupOverviewEditOpportunity, + MusicalGroupOverviewImages, + MusicalGroupOverviewRelated, + WikidataExternalLink, +} from './types' + +export const MUSICAL_GROUP_CACHE_VERSION = 38 + +const STORAGE_KEY = 'musical-group-page-cache' + +export interface CachedCommonsPhotos { + images: CarouselImage[] + seenKeys: string[] + hasMore: boolean +} + +export interface CachedMusicalGroupEntry { + version: number + fetchedAt: number + data: MusicalGroupData + commonsImageCount?: number + commonsImageCountCapped?: boolean + overview?: MusicalGroupOverviewData + externalLinks?: WikidataExternalLink[] + articleHtml?: string + commonsPhotos?: CachedCommonsPhotos +} + +type MusicalGroupCacheStore = Record + +function cacheKey(id: string): string { + return normalizeQid(id) ?? id.trim() +} + +function isEditIndicator(value: unknown): value is EditIndicator { + return value === 'history' || value === 'talk' +} + +function isCarouselImage(value: unknown): value is CarouselImage { + if (typeof value !== 'object' || value === null) return false + + const record = value as Record + return ( + typeof record.url === 'string' && + typeof record.width === 'number' && + record.width > 0 && + typeof record.height === 'number' && + record.height > 0 + ) +} + +function isOverviewArticle(value: unknown): value is MusicalGroupOverviewArticle { + if (typeof value !== 'object' || value === null) return false + const record = value as Record + return ( + typeof record.title === 'string' && + typeof record.extractHtml === 'string' && + typeof record.articleUrl === 'string' && + typeof record.lastEditedTimestamp === 'string' && + typeof record.lastEditedLabel === 'string' && + typeof record.viewCount === 'number' && + typeof record.viewsLabel === 'string' && + typeof record.wordCount === 'number' && + typeof record.wordCountLabel === 'string' && + (record.thumbnailUrl === undefined || typeof record.thumbnailUrl === 'string') + ) +} + +function isOverviewImages(value: unknown): value is MusicalGroupOverviewImages { + if (typeof value !== 'object' || value === null) return false + const record = value as Record + return typeof record.itemCount === 'number' && typeof record.itemCountLabel === 'string' +} + +function isOverviewRelated(value: unknown): value is MusicalGroupOverviewRelated { + if (typeof value !== 'object' || value === null) return false + const record = value as Record + return ( + typeof record.title === 'string' && + typeof record.description === 'string' && + (record.id === undefined || typeof record.id === 'string') && + typeof record.articleUrl === 'string' && + typeof record.lastEditedTimestamp === 'string' && + typeof record.lastEditedLabel === 'string' && + typeof record.viewCount === 'number' && + typeof record.viewsLabel === 'string' && + typeof record.relatedToTitle === 'string' && + (record.thumbnailUrl === undefined || typeof record.thumbnailUrl === 'string') + ) +} + +function isOverviewEditOpportunity(value: unknown): value is MusicalGroupOverviewEditOpportunity { + if (typeof value !== 'object' || value === null) return false + const record = value as Record + return ( + typeof record.title === 'string' && + typeof record.body === 'string' && + typeof record.need === 'string' && + typeof record.score === 'number' + ) +} + +function isHomeRecentChangeFlag(value: unknown): value is HomeRecentChangeFlag { + return ( + value === 'first-edit' || + value === 'new-editor' || + value === 'good-faith' || + value === 'needs-reference' || + value === 'tone-issue' || + value === 'high-revert-risk' || + value === 'none' + ) +} + +function isHomeRecentChange(value: unknown): value is HomeRecentChange { + if (typeof value !== 'object' || value === null) return false + const record = value as Record + return ( + typeof record.enwikiTitle === 'string' && + typeof record.title === 'string' && + typeof record.editSummary === 'string' && + typeof record.diffUrl === 'string' && + typeof record.revid === 'number' && + isHomeRecentChangeFlag(record.flag) && + typeof record.reverted === 'boolean' && + typeof record.isLatest === 'boolean' && + typeof record.editedTimestamp === 'string' && + typeof record.editedLabel === 'string' && + (record.thumbnailUrl === undefined || typeof record.thumbnailUrl === 'string') + ) +} + +function isInfoboxValue(value: unknown): boolean { + if (typeof value !== 'object' || value === null) return false + const v = value as Record + if (typeof v.text !== 'string') return false + return v.href === undefined || typeof v.href === 'string' +} + +function isInfobox(value: unknown): value is MusicalGroupInfobox { + if (typeof value !== 'object' || value === null) return false + const record = value as Record + if (!Array.isArray(record.rows)) return false + return record.rows.every((row) => { + if (typeof row !== 'object' || row === null) return false + const r = row as Record + return ( + typeof r.label === 'string' && + Array.isArray(r.values) && + r.values.every(isInfoboxValue) && + (r.variant === undefined || r.variant === 'header' || r.variant === 'row') + ) + }) +} + +function isOverviewData(value: unknown): value is MusicalGroupOverviewData { + if (typeof value !== 'object' || value === null) return false + const record = value as Record + if (typeof record.fetchedAt !== 'number') return false + if (record.noEnglishArticle !== undefined && typeof record.noEnglishArticle !== 'boolean') { + return false + } + if (record.article !== undefined && !isOverviewArticle(record.article)) return false + if (record.images !== undefined && !isOverviewImages(record.images)) return false + if (record.editOpportunity !== undefined && !isOverviewEditOpportunity(record.editOpportunity)) { + return false + } + if (record.related !== undefined && !isOverviewRelated(record.related)) return false + if (record.latestEdit !== undefined && !isHomeRecentChange(record.latestEdit)) return false + if (record.infobox !== undefined && !isInfobox(record.infobox)) return false + return true +} + +function isMusicalGroupData(value: unknown): value is MusicalGroupData { + if (typeof value !== 'object' || value === null) return false + + const record = value as Record + if (typeof record.id !== 'string' || !record.id.length) return false + if (typeof record.label !== 'string' || !record.label.length) return false + if (typeof record.isMusicPerformer !== 'boolean') return false + if (typeof record.isLocation !== 'boolean') return false + if (typeof record.isPerson !== 'boolean') return false + if ( + record.showImageCarousel !== undefined && + typeof record.showImageCarousel !== 'boolean' + ) { + return false + } + if (!Array.isArray(record.genres) || !record.genres.every((g) => typeof g === 'string')) { + return false + } + if (!Array.isArray(record.images) || !record.images.every(isCarouselImage)) { + return false + } + + if (record.description !== undefined && typeof record.description !== 'string') return false + if (record.typeLabel !== undefined && typeof record.typeLabel !== 'string') return false + if (record.inceptionYear !== undefined && typeof record.inceptionYear !== 'number') return false + if ( + record.yearKind !== undefined && + record.yearKind !== 'inception' && + record.yearKind !== 'birth' + ) { + return false + } + if (record.websiteUrl !== undefined && typeof record.websiteUrl !== 'string') return false + if (record.websiteHost !== undefined && typeof record.websiteHost !== 'string') return false + if (record.country !== undefined && typeof record.country !== 'string') return false + if (record.population !== undefined && typeof record.population !== 'number') return false + if (record.editIndicator !== undefined && !isEditIndicator(record.editIndicator)) return false + if (record.enwikiTitle !== undefined && typeof record.enwikiTitle !== 'string') return false + if (record.commonsCategory !== undefined && typeof record.commonsCategory !== 'string') return false + if (record.imageFilename !== undefined && typeof record.imageFilename !== 'string') return false + if (record.commonsImageCount !== undefined && typeof record.commonsImageCount !== 'number') { + return false + } + if ( + record.commonsImageCountCapped !== undefined && + typeof record.commonsImageCountCapped !== 'boolean' + ) { + return false + } + + return true +} + +function isExternalLink(value: unknown): value is WikidataExternalLink { + if (typeof value !== 'object' || value === null) return false + const record = value as Record + return ( + typeof record.url === 'string' && + typeof record.displayText === 'string' && + (record.category === 'official' || record.category === 'social' || record.category === 'other') + ) +} + +function isCommonsPhotos(value: unknown): value is CachedCommonsPhotos { + if (typeof value !== 'object' || value === null) return false + const record = value as Record + return ( + Array.isArray(record.images) && + record.images.every(isCarouselImage) && + Array.isArray(record.seenKeys) && + record.seenKeys.every((key) => typeof key === 'string') && + typeof record.hasMore === 'boolean' + ) +} + +function isValidEntry(entry: unknown): entry is CachedMusicalGroupEntry { + if (typeof entry !== 'object' || entry === null) return false + + const record = entry as CachedMusicalGroupEntry + if (record.version !== MUSICAL_GROUP_CACHE_VERSION) return false + if (typeof record.fetchedAt !== 'number') return false + if (!isMusicalGroupData(record.data)) return false + if (record.commonsImageCount !== undefined && typeof record.commonsImageCount !== 'number') { + return false + } + if ( + record.commonsImageCountCapped !== undefined && + typeof record.commonsImageCountCapped !== 'boolean' + ) { + return false + } + if (record.overview !== undefined && !isOverviewData(record.overview)) return false + if (record.externalLinks !== undefined && !record.externalLinks.every(isExternalLink)) { + return false + } + if (record.articleHtml !== undefined && typeof record.articleHtml !== 'string') return false + if (record.commonsPhotos !== undefined && !isCommonsPhotos(record.commonsPhotos)) return false + return true +} + +function normalizeStore(raw: unknown): MusicalGroupCacheStore { + if (typeof raw !== 'object' || raw === null) return {} + + const store: MusicalGroupCacheStore = {} + + for (const [key, entry] of Object.entries(raw as Record)) { + if (!isValidEntry(entry)) continue + + const normalizedKey = cacheKey(entry.data.id) || cacheKey(key) + if (!normalizedKey.length) continue + + const existing = store[normalizedKey] + if (!existing || entry.fetchedAt >= existing.fetchedAt) { + store[normalizedKey] = entry + } + } + + return store +} + +function clearStoredCache(): void { + if (typeof window === 'undefined') return + + try { + window.localStorage.removeItem(STORAGE_KEY) + } catch { + // Private mode or blocked storage — ignore. + } +} + +export function clearMusicalGroupCache(): void { + if (typeof window === 'undefined') return + + try { + window.localStorage.clear() + } catch { + // Private mode or blocked storage — ignore. + } +} + +function readRawStore(): { raw: unknown; corrupt: boolean } { + if (typeof window === 'undefined') return { raw: null, corrupt: false } + + try { + const stored = window.localStorage.getItem(STORAGE_KEY) + if (!stored) return { raw: null, corrupt: false } + return { raw: JSON.parse(stored), corrupt: false } + } catch { + return { raw: null, corrupt: true } + } +} + +function readStore(): MusicalGroupCacheStore { + const { raw, corrupt } = readRawStore() + if (corrupt) { + clearStoredCache() + return {} + } + if (raw === null) return {} + + const normalized = normalizeStore(raw) + if (JSON.stringify(normalized) !== JSON.stringify(raw)) { + persistStore(normalized) + } + return normalized +} + +function persistStore(store: MusicalGroupCacheStore): void { + if (typeof window === 'undefined') return + + const normalized = normalizeStore(store) + + try { + if (Object.keys(normalized).length === 0) { + clearStoredCache() + return + } + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(normalized)) + } catch { + // Quota or private-mode failures — ignore. + } +} + +export function getCachedMusicalGroup(id: string): CachedMusicalGroupEntry | null { + const key = cacheKey(id) + if (!key.length) return null + + const store = readStore() + return store[key] ?? null +} + + +export function setCachedMusicalGroup( + id: string, + data: MusicalGroupData, +): CachedMusicalGroupEntry { + const key = cacheKey(id) || cacheKey(data.id) + const store = readStore() + const existing = key.length ? store[key] : undefined + + const entry: CachedMusicalGroupEntry = { + version: MUSICAL_GROUP_CACHE_VERSION, + fetchedAt: Date.now(), + data, + commonsImageCount: data.commonsImageCount ?? existing?.commonsImageCount, + commonsImageCountCapped: + data.commonsImageCountCapped ?? existing?.commonsImageCountCapped, + overview: existing?.overview, + externalLinks: existing?.externalLinks, + articleHtml: existing?.articleHtml, + commonsPhotos: existing?.commonsPhotos, + } + + if (!key.length) return entry + + persistStore({ ...store, [key]: entry }) + return entry +} + +export function setCachedMusicalGroupOverview( + id: string, + overview: MusicalGroupOverviewData, +): CachedMusicalGroupEntry | null { + const key = cacheKey(id) + if (!key.length) return null + + const store = readStore() + const existing = store[key] + if (!existing) return null + + const entry: CachedMusicalGroupEntry = { + ...existing, + overview, + } + + persistStore({ ...store, [key]: entry }) + return entry +} + +function updateCachedEntry( + id: string, + patch: Partial< + Pick< + CachedMusicalGroupEntry, + 'overview' | 'externalLinks' | 'articleHtml' | 'commonsPhotos' + > + >, +): CachedMusicalGroupEntry | null { + const key = cacheKey(id) + if (!key.length) return null + + const store = readStore() + const existing = store[key] + if (!existing) return null + + const entry: CachedMusicalGroupEntry = { + ...existing, + ...patch, + } + + persistStore({ ...store, [key]: entry }) + return entry +} + +export function setCachedMusicalGroupExternalLinks( + id: string, + externalLinks: WikidataExternalLink[], +): CachedMusicalGroupEntry | null { + return updateCachedEntry(id, { externalLinks }) +} + +export function setCachedMusicalGroupArticleHtml( + id: string, + articleHtml: string, +): CachedMusicalGroupEntry | null { + return updateCachedEntry(id, { articleHtml }) +} + +export function setCachedMusicalGroupCommonsPhotos( + id: string, + commonsPhotos: CachedCommonsPhotos, +): CachedMusicalGroupEntry | null { + return updateCachedEntry(id, { commonsPhotos }) +} diff --git a/src/prototypes/musical-group/data/pageSummary.ts b/src/prototypes/musical-group/data/pageSummary.ts new file mode 100644 index 0000000..fe33645 --- /dev/null +++ b/src/prototypes/musical-group/data/pageSummary.ts @@ -0,0 +1,73 @@ +import { wikimediaApiFetchHeaders } from '@/config' +import { fetchWikimedia } from '@/lib/fetchWikimedia' + +import { EN_WIKI_HOST } from './enwikiTitle' +import { + getCachedPageSummary, + getPageSummaryInFlight, + setCachedPageSummary, + setPageSummaryInFlight, +} from './pageSummaryCache' + +export interface PageSummary { + title?: string + normalizedtitle?: string + description?: string + extract?: string + thumbnail?: { source?: string } + timestamp?: string + content_urls?: { desktop?: { page?: string } } + wikibase_item?: string +} + +async function fetchPageSummaryFromNetwork( + title: string, + signal?: AbortSignal, + purpose = 'musical-group-home-summary', +): Promise { + const slug = encodeURIComponent(title.replace(/ /g, '_')) + const response = await fetchWikimedia( + `https://${EN_WIKI_HOST}/api/rest_v1/page/summary/${slug}`, + { + signal, + headers: wikimediaApiFetchHeaders(purpose), + }, + ) + if (!response.ok) return null + return (await response.json()) as PageSummary +} + +export interface FetchPageSummaryOptions { + /** Retry the network when the only cached value is a prior failure (`null`). */ + bypassFailureCache?: boolean +} + +/** REST `/page/summary/{title}` on English Wikipedia. */ +export async function fetchPageSummary( + title: string, + signal?: AbortSignal, + purpose = 'musical-group-home-summary', + options?: FetchPageSummaryOptions, +): Promise { + const cached = getCachedPageSummary(title) + if (cached !== undefined) { + if (cached !== null || !options?.bypassFailureCache) return cached + } + + const inFlight = getPageSummaryInFlight(title) + if (inFlight) return inFlight + + const promise = fetchPageSummaryFromNetwork(title, signal, purpose) + .then((summary) => { + setCachedPageSummary(title, summary) + return summary + }) + .catch((err) => { + if ((err as Error).name === 'AbortError') throw err + setCachedPageSummary(title, null) + return null + }) + + setPageSummaryInFlight(title, promise) + return promise +} diff --git a/src/prototypes/musical-group/data/pageSummaryCache.ts b/src/prototypes/musical-group/data/pageSummaryCache.ts new file mode 100644 index 0000000..547c3bd --- /dev/null +++ b/src/prototypes/musical-group/data/pageSummaryCache.ts @@ -0,0 +1,96 @@ +import { normalizeEnwikiTitle } from './enwikiTitle' +import type { PageSummary } from './pageSummary' +import { readVersionedStore, setVersionedEntry, writeVersionedStore } from './wikitaCache' + +const STORAGE_KEY = 'musical-group-page-summary-cache' +const CACHE_VERSION = 1 + +interface CachedPageSummaryEntry { + summary: PageSummary | null + fetchedAt: number +} + +const memoryCache = new Map() +const inFlight = new Map>() + +function titleCacheKey(title: string): string { + return normalizeEnwikiTitle(title).toLowerCase() +} + +function isValidEntry(entry: unknown): entry is CachedPageSummaryEntry { + if (typeof entry !== 'object' || entry === null) return false + const record = entry as CachedPageSummaryEntry + if (typeof record.fetchedAt !== 'number') return false + if (record.summary !== null && typeof record.summary !== 'object') return false + return true +} + +function readEntries(): Record { + return readVersionedStore(STORAGE_KEY, CACHE_VERSION, isValidEntry) +} + +export function getCachedPageSummary(title: string): PageSummary | null | undefined { + const key = titleCacheKey(title) + if (memoryCache.has(key)) return memoryCache.get(key) + + const stored = readEntries()[key] + if (!stored) return undefined + + memoryCache.set(key, stored.summary) + return stored.summary +} + +export function setCachedPageSummary(title: string, summary: PageSummary | null): void { + const key = titleCacheKey(title) + memoryCache.set(key, summary) + setVersionedEntry( + STORAGE_KEY, + CACHE_VERSION, + key, + { summary, fetchedAt: Date.now() }, + isValidEntry, + ) +} + +export function getPageSummaryInFlight(title: string): Promise | undefined { + return inFlight.get(titleCacheKey(title)) +} + +export function setPageSummaryInFlight( + title: string, + promise: Promise, +): void { + const key = titleCacheKey(title) + inFlight.set(key, promise) + promise.finally(() => { + if (inFlight.get(key) === promise) inFlight.delete(key) + }) +} + +export function clearPageSummaryCache(): void { + memoryCache.clear() + inFlight.clear() + if (typeof window === 'undefined') return + try { + window.localStorage.removeItem(STORAGE_KEY) + } catch { + // Ignore. + } +} + +/** Drop in-memory layer only (localStorage kept). Used rarely. */ +export function clearPageSummaryMemoryCache(): void { + memoryCache.clear() + inFlight.clear() +} + +export function persistPageSummaryBatch(entries: Record): void { + const store = readEntries() + const now = Date.now() + for (const [title, summary] of Object.entries(entries)) { + const key = titleCacheKey(title) + store[key] = { summary, fetchedAt: now } + memoryCache.set(key, summary) + } + writeVersionedStore(STORAGE_KEY, CACHE_VERSION, store) +} diff --git a/src/prototypes/musical-group/data/parseWikitaArticle.ts b/src/prototypes/musical-group/data/parseWikitaArticle.ts new file mode 100644 index 0000000..72886e4 --- /dev/null +++ b/src/prototypes/musical-group/data/parseWikitaArticle.ts @@ -0,0 +1,265 @@ +import { parseEnwikiArticleTitle } from './enwikiTitle' + +export type WikitaArticleBlock = + | { type: 'prose'; html: string } + | { type: 'table'; html: string; caption?: string } + | { type: 'sidebar'; html: string } + | { type: 'notice'; html: string } + +function removeNavboxStylesWrappers(root: ParentNode): void { + root.querySelectorAll('.navbox-styles').forEach((node) => node.remove()) +} + +/** Short descriptions and hatnotes are shown elsewhere in Wikita chrome. */ +function removeLeadNotices(root: ParentNode): void { + root + .querySelectorAll('.shortdescription, .hatnote, .dablink, .rellink') + .forEach((node) => node.remove()) + root.querySelectorAll('style').forEach((node) => node.remove()) +} + +function normalizeInlineReferences(root: ParentNode): void { + root.querySelectorAll('sup.reference').forEach((node) => { + const anchor = node.querySelector('a[href]') + if (!anchor) { + node.remove() + return + } + + for (const attr of ['about', 'typeof', 'rel', 'data-mw']) { + node.removeAttribute(attr) + } + node.classList.add('wikita-ref') + + anchor.removeAttribute('id') + for (const attr of ['about', 'typeof', 'data-mw', 'rel']) { + anchor.removeAttribute(attr) + } + anchor.querySelectorAll('[id]').forEach((el) => { + el.removeAttribute('id') + }) + }) +} + +function cleanArticleTree(root: ParentNode): void { + normalizeInlineReferences(root) + root.querySelectorAll('.mw-editsection, .mw-empty-elt').forEach((node) => { + node.remove() + }) + root.querySelectorAll('[style]').forEach((node) => { + if (node.closest('.navbox')) return + // Location maps position their marker with inline top/left percentages. + if (node.closest('.locmap')) return + node.removeAttribute('style') + }) +} + +/** Infobox facts live on the Info tab; strip them from article prose. */ +function removeInfoboxes(root: ParentNode): void { + root.querySelectorAll('.portable-infobox-wrapper').forEach((node) => node.remove()) + root.querySelectorAll('table.infobox, table.infobox-v2, .portable-infobox').forEach((node) => node.remove()) +} + +function findExternalLinksSection(root: ParentNode): Element | null { + const heading = root.querySelector('h2#External_links') + if (!heading) return null + return heading.closest('section') ?? heading.parentElement +} + +function removeExternalLinksSection(root: ParentNode): void { + const section = findExternalLinksSection(root) + section?.remove() +} + +function flattenContentNodes(body: Element): Element[] { + const nodes: Element[] = [] + for (const child of Array.from(body.children)) { + if (child.tagName === 'SECTION') { + nodes.push(...Array.from(child.children)) + } else { + nodes.push(child) + } + } + return nodes +} + +function isWikitable(element: Element): boolean { + return element.tagName === 'TABLE' && element.classList.contains('wikitable') +} + +function isSidebar(element: Element): boolean { + return element.tagName === 'TABLE' && element.classList.contains('sidebar') +} + +/** Wikipedia "part of a series about …" navigation templates (e.g. {{Keir Starmer sidebar}}). */ +function isSeriesSidebar(element: Element): boolean { + if (!isSidebar(element)) return false + + if (element.classList.contains('sidebar-person')) return true + + const text = element.textContent?.replace(/\s+/g, ' ').trim().toLowerCase() ?? '' + if (text.includes('part of a series about')) return true + if (text.includes('this article is part of') && text.includes('series')) return true + + return false +} + +function removeSeriesSidebars(root: ParentNode): void { + root.querySelectorAll('table.sidebar').forEach((table) => { + if (!isSeriesSidebar(table)) return + + const previous = table.previousElementSibling + if (previous?.classList.contains('mw-empty-elt') && !previous.textContent?.trim()) { + previous.remove() + } + table.remove() + }) +} + +function isMaintenanceNotice(element: Element): boolean { + return element.tagName === 'TABLE' && element.classList.contains('ambox') +} + +function normalizeNoticeHtml(element: Element): string { + const clone = element.cloneNode(true) as Element + clone.querySelectorAll('.hide-when-compact, sup.reference, .mw-editsection').forEach((node) => { + node.remove() + }) + clone.querySelectorAll('[style]').forEach((node) => { + node.removeAttribute('style') + }) + + const iconCell = clone.querySelector('.mbox-image') + const textCell = clone.querySelector('.mbox-text') ?? clone.querySelector('.mbox-text-span') + const iconHtml = iconCell?.innerHTML.trim() ?? '' + const textHtml = (textCell?.innerHTML ?? clone.textContent ?? '').trim() + + if (!iconHtml) { + return `
${textHtml}
` + } + + return `
${iconHtml}
${textHtml}
` +} + +function splitWikitable(element: Element): { html: string; caption?: string } { + const clone = element.cloneNode(true) as Element + const captionEl = clone.querySelector('caption') + let caption: string | undefined + + if (captionEl) { + const captionHtml = captionEl.innerHTML.trim() + if (captionHtml) caption = captionHtml + captionEl.remove() + } + + return { html: clone.outerHTML, caption } +} + +function pushWikitableBlock(element: Element, blocks: WikitaArticleBlock[]): void { + blocks.push({ type: 'table', ...splitWikitable(element) }) +} + +function pushSidebarBlock(element: Element, blocks: WikitaArticleBlock[]): void { + blocks.push({ type: 'sidebar', html: element.outerHTML }) +} + +function hasMeaningfulText(element: Element): boolean { + return Boolean(element.textContent?.replace(/\s+/g, '').length) +} + +function flushProse(parts: string[], blocks: WikitaArticleBlock[]): void { + const html = parts.join('\n').trim() + if (html) blocks.push({ type: 'prose', html }) + parts.length = 0 +} + +function processElements( + elements: Element[], + proseParts: string[], + blocks: WikitaArticleBlock[], +): void { + for (const element of elements) { + if (isMaintenanceNotice(element)) { + flushProse(proseParts, blocks) + blocks.push({ type: 'notice', html: normalizeNoticeHtml(element) }) + continue + } + + if (isWikitable(element)) { + flushProse(proseParts, blocks) + pushWikitableBlock(element, blocks) + continue + } + + if (isSidebar(element)) { + if (isSeriesSidebar(element)) continue + flushProse(proseParts, blocks) + pushSidebarBlock(element, blocks) + continue + } + + const directTables = Array.from(element.children).filter(isWikitable) + const directSidebars = Array.from(element.children).filter(isSidebar) + const directNotices = Array.from(element.children).filter(isMaintenanceNotice) + if (directTables.length > 0 || directSidebars.length > 0 || directNotices.length > 0) { + processElements(Array.from(element.children), proseParts, blocks) + continue + } + + if ( + element.querySelector('table.wikitable') || + element.querySelector('table.sidebar') || + element.querySelector('table.ambox') + ) { + processElements(Array.from(element.children), proseParts, blocks) + continue + } + + if (hasMeaningfulText(element)) { + proseParts.push(element.outerHTML) + } + } +} + +export function parseWikitaArticleBlocks(html: string): WikitaArticleBlock[] { + const doc = new DOMParser().parseFromString(html, 'text/html') + removeLeadNotices(doc) + removeNavboxStylesWrappers(doc) + cleanArticleTree(doc.body) + removeInfoboxes(doc.body) + removeSeriesSidebars(doc.body) + removeExternalLinksSection(doc.body) + + const nodes = flattenContentNodes(doc.body) + const blocks: WikitaArticleBlock[] = [] + const proseParts: string[] = [] + + processElements(nodes, proseParts, blocks) + flushProse(proseParts, blocks) + + return blocks +} + +/** + * Collect unique enwiki article titles linked from article HTML blocks. + */ +export function collectArticleLinkTitles(blocks: WikitaArticleBlock[]): string[] { + const titles = new Set() + + for (const block of blocks) { + const htmlParts = + block.type === 'table' + ? [block.html, block.caption].filter((part): part is string => Boolean(part)) + : [block.html] + for (const html of htmlParts) { + const doc = new DOMParser().parseFromString(html, 'text/html') + for (const anchor of Array.from(doc.querySelectorAll('a[href]'))) { + const href = anchor.getAttribute('href') ?? '' + const title = parseEnwikiArticleTitle(href) + if (title) titles.add(title) + } + } + } + + return [...titles] +} diff --git a/src/prototypes/musical-group/data/relatedToLabel.ts b/src/prototypes/musical-group/data/relatedToLabel.ts new file mode 100644 index 0000000..9e70e2c --- /dev/null +++ b/src/prototypes/musical-group/data/relatedToLabel.ts @@ -0,0 +1,46 @@ +import { listBookmarks } from './bookmarks' +import { getCachedMusicalGroup } from './musicalGroupCache' +import type { HomeSavedItem } from './types' + +function normalizeDisplayTitle(title: string): string { + return title.trim().toLowerCase() +} + +export function isSavedPageTitle( + title: string, + savedItems: Pick[], +): boolean { + const key = normalizeDisplayTitle(title) + return savedItems.some((item) => normalizeDisplayTitle(item.title) === key) +} + +/** Show "Related to …" only when the seed page is not in the user's saved library. */ +export function formatRelatedToLabel( + relatedToTitle: string, + savedItems: Pick[], + options?: { alwaysShow?: boolean }, +): string { + if (!relatedToTitle) return '' + if (!options?.alwaysShow && isSavedPageTitle(relatedToTitle, savedItems)) return '' + return `Related to ${relatedToTitle}` +} + +/** "Related to …" for edit suggestions seeded from a saved page onto a different article. */ +export function formatEditSuggestionRelatedToLabel( + suggestion: Pick & { relatedToTitle: string }, + savedItems: Pick[], +): string { + const isSavedPageSuggestion = savedItems.some((item) => item.id === suggestion.itemId) + if (isSavedPageSuggestion) return '' + return formatRelatedToLabel(suggestion.relatedToTitle, savedItems, { alwaysShow: true }) +} + +/** Saved page titles from the local entity cache (for views outside the home feed). */ +export function getCachedSavedPageTitles(): Pick[] { + return listBookmarks() + .map((entry) => { + const cached = getCachedMusicalGroup(entry.id) + return cached ? { title: cached.data.label } : null + }) + .filter((item): item is Pick => item !== null) +} diff --git a/src/prototypes/musical-group/data/resetAllStoredData.ts b/src/prototypes/musical-group/data/resetAllStoredData.ts new file mode 100644 index 0000000..11ea465 --- /dev/null +++ b/src/prototypes/musical-group/data/resetAllStoredData.ts @@ -0,0 +1,26 @@ +import { clearWikimediaFetchQueues } from '@/lib/fetchWikimedia' + +import { clearCommonsImageCache } from './commonsImages' +import { clearFeaturedTabSessionCache } from './fetchFeaturedFeed' +import { clearFeaturedFeedSessionCache } from './fetchEnwikiFeaturedFeedDay' +import { clearTrendingSessionCache } from './fetchTrending' +import { clearWikitaArticleMemoryCache } from './fetchWikitaArticle' +import { clearHomeTabCache } from './homeTabCache' +import { clearLiftWingCache, clearLiftWingMemoryCache } from './liftWing' +import { clearMusicalGroupCache } from './musicalGroupCache' +import { clearPageSummaryCache } from './pageSummaryCache' + +/** Reset button: wipe all stored data and in-memory caches. */ +export function resetAllStoredData(): void { + clearMusicalGroupCache() + clearHomeTabCache() + clearPageSummaryCache() + clearCommonsImageCache() + clearLiftWingCache() + clearLiftWingMemoryCache() + clearFeaturedFeedSessionCache() + clearFeaturedTabSessionCache() + clearTrendingSessionCache() + clearWikitaArticleMemoryCache() + clearWikimediaFetchQueues() +} diff --git a/src/prototypes/musical-group/data/resolvePrimaryOccupation.ts b/src/prototypes/musical-group/data/resolvePrimaryOccupation.ts new file mode 100644 index 0000000..a356062 --- /dev/null +++ b/src/prototypes/musical-group/data/resolvePrimaryOccupation.ts @@ -0,0 +1,272 @@ +import { wikimediaApiFetchHeaders } from '@/config' + +import { fetchWikimedia } from '@/lib/fetchWikimedia' +import { sentenceCase } from './formatLabel' +import { + ACTOR_OCCUPATION_QIDS, + MUSIC_PERFORMER_QIDS, + SPORTS_OCCUPATION_QIDS, +} from './types' + +const WIKIDATA_SPARQL = 'https://query.wikidata.org/sparql' + +/** Broad writing sideline roles — skipped when a more specific P106 exists. */ +const GENERIC_SIDELINE_QIDS = [ + 'Q36180', // writer + 'Q18814623', // autobiographer +] as const + +/** Visual / performance creatives that keep the intro image carousel for people. */ +const CREATIVE_CAROUSEL_QIDS = [ + 'Q483501', // artist + 'Q3391743', // visual artist + 'Q2526255', // film director + 'Q7042855', // television director + 'Q3282637', // film producer + 'Q1028181', // painter + 'Q1281618', // sculptor + 'Q28389', // screenwriter + 'Q214917', // playwright + 'Q33231', // photographer + 'Q13235160', // fashion designer + 'Q49757', // poet +] as const + +export interface OccupationResolution { + primaryId?: string + primaryIsMusic: boolean + primaryIsActor: boolean + primaryIsCreative: boolean + primaryIsSports: boolean + /** Any non-deprecated P106 subclasses a music-performer anchor. */ + hasMusicOccupation: boolean + /** Any non-deprecated P106 subclasses an actor anchor. */ + hasActorOccupation: boolean +} + +interface OccupationSparqlRow { + occ: { value: string } + occLabel?: { value: string } + music?: { value: string } + actor?: { value: string } + creative?: { value: string } + sports?: { value: string } + generic?: { value: string } +} + +interface OccupationRow { + id: string + label?: string + isMusic: boolean + isActor: boolean + isCreative: boolean + isSports: boolean + isGenericSideline: boolean +} + +const EMPTY_RESOLUTION: OccupationResolution = { + primaryIsMusic: false, + primaryIsActor: false, + primaryIsCreative: false, + primaryIsSports: false, + hasMusicOccupation: false, + hasActorOccupation: false, +} + +function qidFromUri(uri: string): string { + return uri.replace(/^.*\//, '') +} + +function valuesClause(variable: string, qids: readonly string[]): string { + const values = qids.map((qid) => `wd:${qid}`).join(' ') + return `VALUES ${variable} { ${values} }` +} + +function hitFlag(value: string | undefined): boolean { + return value === '1' || value === 'true' +} + +async function sparqlQuery(query: string, signal?: AbortSignal): Promise { + const url = `${WIKIDATA_SPARQL}?query=${encodeURIComponent(query)}` + const response = await fetchWikimedia(url, { + signal, + headers: { + Accept: 'application/sparql-results+json', + ...wikimediaApiFetchHeaders('musical-group-occupation-sparql'), + }, + }) + if (!response.ok) { + throw new Error(`Occupation SPARQL request failed (${response.status})`) + } + return response.json() as Promise +} + +function defaultRow(id: string): OccupationRow { + return { + id, + isMusic: false, + isActor: false, + isCreative: false, + isSports: false, + isGenericSideline: false, + } +} + +function parseOccupationRows(bindings: OccupationSparqlRow[]): Map { + const rows = new Map() + for (const row of bindings) { + const id = qidFromUri(row.occ.value) + rows.set(id, { + id, + label: row.occLabel?.value, + isMusic: hitFlag(row.music?.value), + isActor: hitFlag(row.actor?.value), + isCreative: hitFlag(row.creative?.value), + isSports: hitFlag(row.sports?.value), + isGenericSideline: hitFlag(row.generic?.value), + }) + } + return rows +} + +function pickPrimaryId( + occupationIds: string[], + preferredIds: string[], +): string | undefined { + if (!occupationIds.length) return undefined + + for (const id of preferredIds) { + if (occupationIds.includes(id)) return id + } + + return occupationIds[0] +} + +async function fetchOccupationRows( + occupationIds: string[], + signal?: AbortSignal, +): Promise> { + const occValues = valuesClause('?occ', occupationIds) + const musicValues = valuesClause('?musicAnchor', MUSIC_PERFORMER_QIDS) + const actorValues = valuesClause('?actorAnchor', ACTOR_OCCUPATION_QIDS) + const creativeValues = valuesClause('?creativeAnchor', CREATIVE_CAROUSEL_QIDS) + const sportsValues = valuesClause('?sportsAnchor', SPORTS_OCCUPATION_QIDS) + const genericValues = valuesClause('?genericAnchor', GENERIC_SIDELINE_QIDS) + + const sparql = ` +SELECT ?occ ?occLabel + (MAX(?musicHit) AS ?music) + (MAX(?actorHit) AS ?actor) + (MAX(?creativeHit) AS ?creative) + (MAX(?sportsHit) AS ?sports) + (MAX(?genericHit) AS ?generic) +WHERE { + ${occValues} + OPTIONAL { + ?occ wdt:P279* ?musicAnchor . + ${musicValues} + BIND(1 AS ?musicHit) + } + OPTIONAL { + ?occ wdt:P279* ?actorAnchor . + ${actorValues} + BIND(1 AS ?actorHit) + } + OPTIONAL { + ?occ wdt:P279* ?creativeAnchor . + ${creativeValues} + BIND(1 AS ?creativeHit) + } + OPTIONAL { + ?occ wdt:P279* ?sportsAnchor . + ${sportsValues} + BIND(1 AS ?sportsHit) + } + OPTIONAL { + ?occ wdt:P279* ?genericAnchor . + ${genericValues} + BIND(1 AS ?genericHit) + } + SERVICE wikibase:label { bd:serviceParam wikibase:language "en". } +} +GROUP BY ?occ ?occLabel` + + const data = await sparqlQuery<{ results: { bindings: OccupationSparqlRow[] } }>( + sparql, + signal, + ) + return parseOccupationRows(data.results.bindings) +} + +/** + * Resolve P106 flags for carousel rules and pick the primary occupation from + * Wikidata claim rank (preferred, then normal) and statement order. + */ +export async function resolveOccupations( + occupationIds: string[], + options: { + preferredIds?: string[] + signal?: AbortSignal + } = {}, +): Promise { + if (!occupationIds.length) return { ...EMPTY_RESOLUTION } + + const preferredIds = options.preferredIds ?? [] + + let rows: Map + try { + rows = await fetchOccupationRows(occupationIds, options.signal) + } catch { + const primaryId = pickPrimaryId(occupationIds, preferredIds) + return { + ...EMPTY_RESOLUTION, + primaryId, + hasMusicOccupation: occupationIds.some((id) => + (MUSIC_PERFORMER_QIDS as readonly string[]).includes(id), + ), + hasActorOccupation: occupationIds.some((id) => + (ACTOR_OCCUPATION_QIDS as readonly string[]).includes(id), + ), + } + } + + const rowFor = (id: string): OccupationRow => rows.get(id) ?? defaultRow(id) + const primaryId = pickPrimaryId(occupationIds, preferredIds) + const primary = primaryId ? rowFor(primaryId) : undefined + const hasMusicOccupation = occupationIds.some((id) => rowFor(id).isMusic) + const hasActorOccupation = occupationIds.some((id) => rowFor(id).isActor) + + return { + primaryId, + primaryIsMusic: primary?.isMusic ?? false, + primaryIsActor: primary?.isActor ?? false, + primaryIsCreative: primary?.isCreative ?? false, + primaryIsSports: primary?.isSports ?? false, + hasMusicOccupation, + hasActorOccupation, + } +} + +export function primaryOccupationLabel( + labelMap: Map, + resolution: OccupationResolution, +): string | undefined { + if (!resolution.primaryId) return undefined + const fromMap = labelMap.get(resolution.primaryId) + if (fromMap) return sentenceCase(fromMap) + return undefined +} + +/** Whether a person's primary occupation warrants the intro image carousel. */ +export function personShowsImageCarousel( + resolution: OccupationResolution, + actorMusician = false, +): boolean { + if (actorMusician) return false + return ( + resolution.primaryIsMusic || + resolution.primaryIsActor || + resolution.primaryIsCreative || + resolution.primaryIsSports + ) +} diff --git a/src/prototypes/musical-group/data/socialPlatforms.ts b/src/prototypes/musical-group/data/socialPlatforms.ts new file mode 100644 index 0000000..3240491 --- /dev/null +++ b/src/prototypes/musical-group/data/socialPlatforms.ts @@ -0,0 +1,85 @@ +/** Wikidata external-id properties treated as social / streaming links. */ +export const SOCIAL_PROPERTY_IDS = [ + 'P2002', // X (Twitter) username + 'P2003', // Instagram username + 'P2013', // Facebook ID + 'P2397', // YouTube channel ID + 'P7085', // TikTok username + 'P4264', // LinkedIn personal profile + 'P4033', // Mastodon address + 'P12386', // Bluesky handle + 'P1902', // Spotify artist ID + 'P2722', // Deezer artist ID + 'P2850', // Apple Music artist ID (U.S.) + 'P3040', // SoundCloud ID + 'P7192', // Bandcamp profile ID + 'P4576', // Tidal artist ID +] as const + +const SOCIAL_PROPERTY_LABELS: Record = { + P2002: 'X', + P2003: 'Instagram', + P2013: 'Facebook', + P2397: 'YouTube', + P7085: 'TikTok', + P4264: 'LinkedIn', + P4033: 'Mastodon', + P12386: 'Bluesky', + P1902: 'Spotify', + P2722: 'Deezer', + P2850: 'Apple Music', + P3040: 'SoundCloud', + P7192: 'Bandcamp', + P4576: 'Tidal', +} + +const SOCIAL_HOST_LABELS: { pattern: RegExp; label: string }[] = [ + { pattern: /(^|\.)instagram\.com$/i, label: 'Instagram' }, + { pattern: /(^|\.)bandcamp\.com$/i, label: 'Bandcamp' }, + { pattern: /(^|\.)twitter\.com$/i, label: 'X' }, + { pattern: /(^|\.)x\.com$/i, label: 'X' }, + { pattern: /(^|\.)facebook\.com$/i, label: 'Facebook' }, + { pattern: /(^|\.)fb\.com$/i, label: 'Facebook' }, + { pattern: /(^|\.)youtube\.com$/i, label: 'YouTube' }, + { pattern: /(^|\.)youtu\.be$/i, label: 'YouTube' }, + { pattern: /(^|\.)tiktok\.com$/i, label: 'TikTok' }, + { pattern: /(^|\.)linkedin\.com$/i, label: 'LinkedIn' }, + { pattern: /(^|\.)bsky\.app$/i, label: 'Bluesky' }, + { pattern: /(^|\.)spotify\.com$/i, label: 'Spotify' }, + { pattern: /(^|\.)deezer\.com$/i, label: 'Deezer' }, + { pattern: /(^|\.)music\.apple\.com$/i, label: 'Apple Music' }, + { pattern: /(^|\.)soundcloud\.com$/i, label: 'SoundCloud' }, + { pattern: /(^|\.)tidal\.com$/i, label: 'Tidal' }, +] + +export const socialPropertyRank = new Map( + SOCIAL_PROPERTY_IDS.map((propertyId, index) => [propertyId, index]), +) + +export function isSocialPropertyId(propertyId: string): boolean { + return socialPropertyRank.has(propertyId) +} + +export function socialLabelFromHost(url: string): string | null { + try { + const hostname = new URL(url).hostname.replace(/^www\./, '') + for (const { pattern, label } of SOCIAL_HOST_LABELS) { + if (pattern.test(hostname)) return label + } + } catch { + // ignore invalid URLs + } + return null +} + +export function getSocialPlatformLabel(url: string, propertyId?: string): string | null { + if (propertyId && SOCIAL_PROPERTY_LABELS[propertyId]) { + return SOCIAL_PROPERTY_LABELS[propertyId] + } + return socialLabelFromHost(url) +} + +export function isSocialPlatformUrl(url: string, propertyId?: string): boolean { + if (propertyId && isSocialPropertyId(propertyId)) return true + return socialLabelFromHost(url) !== null +} diff --git a/src/prototypes/musical-group/data/types.ts b/src/prototypes/musical-group/data/types.ts new file mode 100644 index 0000000..6daa208 --- /dev/null +++ b/src/prototypes/musical-group/data/types.ts @@ -0,0 +1,357 @@ +/** Wikidata anchor classes for music performers (expanded via P31/P279* in SPARQL). */ +export const MUSIC_PERFORMER_QIDS = [ + 'Q215380', // musical group + 'Q639669', // musician + 'Q36834', // composer + 'Q177220', // singer + 'Q753110', // songwriter +] as const + +/** Wikidata anchor classes for geographic locations (expanded via P31/P279* in SPARQL). */ +export const LOCATION_QIDS = [ + 'Q515', // city + 'Q6256', // country + 'Q35657', // state + 'Q532', // village + 'Q23442', // island + 'Q486972', // human settlement +] as const + +/** Wikidata anchor classes for people (expanded via P31/P279* in SPARQL). */ +export const PERSON_QIDS = [ + 'Q5', // human +] as const + +/** Occupations treated as on-screen / performance actors for carousel rules. */ +export const ACTOR_OCCUPATION_QIDS = [ + 'Q33999', // actor + 'Q10798782', // television actor + 'Q2405480', // voice actor +] as const + +/** Occupations treated as sportspeople for carousel rules (P279* of athlete). */ +export const SPORTS_OCCUPATION_QIDS = [ + 'Q2066131', // athlete + 'Q10833314', // sportsperson +] as const + +export function occupationMatches( + occupationIds: string[], + anchors: readonly string[], +): boolean { + return occupationIds.some((id) => (anchors as readonly string[]).includes(id)) +} + +/** Whether an entity profile should show the intro image carousel. */ +export function resolveShowImageCarousel(input: { + isMusicPerformer: boolean + isLocation: boolean + isPerson: boolean + actorMusician?: boolean + personShowsCarousel?: boolean +}): boolean { + if (input.isMusicPerformer || input.isLocation) return true + if (!input.isPerson || input.actorMusician) return false + return Boolean(input.personShowsCarousel) +} + +/** Resolve carousel visibility from stored data, including older cache entries. */ +export function showImageCarouselFor(data: MusicalGroupData): boolean { + if (typeof data.showImageCarousel === 'boolean') return data.showImageCarousel + if (data.isMusicPerformer || data.isLocation) return true + return false +} + +export type YearKind = 'inception' | 'birth' + +export type TabId = + | 'overview' + | 'info' + | 'article' + | 'images' + | 'links' + | 'activity' + | 'contribute' + +export type EditIndicator = 'history' | 'talk' + +export type ExternalLinkCategory = 'official' | 'social' | 'other' + +export interface WikidataExternalLink { + url: string + displayText: string + category: ExternalLinkCategory +} + +export interface MusicalGroupSearchResult { + id: string + label: string + description?: string + thumbnailUrl?: string +} + +export interface CarouselImage { + url: string + width: number + height: number + /** Canonical `File:` title on Wikimedia Commons. */ + title?: string +} + +export interface MusicalGroupOverviewArticle { + title: string + extractHtml: string + thumbnailUrl?: string + articleUrl: string + lastEditedTimestamp: string + lastEditedLabel: string + viewCount: number + viewsLabel: string + wordCount: number + wordCountLabel: string +} + +export interface MusicalGroupOverviewImages { + itemCount: number + itemCountLabel: string +} + +export interface MusicalGroupOverviewRelated { + id?: string + title: string + description: string + thumbnailUrl?: string + articleUrl: string + lastEditedTimestamp: string + lastEditedLabel: string + viewCount: number + viewsLabel: string + /** Display title of the page this recommendation was seeded from. */ + relatedToTitle: string +} + +/** + * A "Snippet" card (labelled "Mentioned" to users): an article that mentions the + * item, shown with the highlighted search snippet where the mention occurs. + */ +export interface MusicalGroupOverviewSnippet { + /** Wikidata item of the mentioning page, when it has one (opens inside Wikita). */ + id?: string + /** Title of the page that mentions the item. */ + title: string + /** REST summary short description of the mentioning page. */ + description: string + /** Search snippet HTML, including `` highlights. */ + snippetHtml: string + thumbnailUrl?: string + articleUrl: string +} + +export interface MusicalGroupOverviewEditOpportunity { + title: string + body: string + need: string + score: number +} + +export interface MusicalGroupInfoboxValue { + text: string + href?: string +} + +export interface MusicalGroupInfoboxRow { + label: string + values: MusicalGroupInfoboxValue[] + variant?: 'header' | 'row' +} + +export interface MusicalGroupInfobox { + rows: MusicalGroupInfoboxRow[] +} + +export interface MusicalGroupOverviewData { + article?: MusicalGroupOverviewArticle + images?: MusicalGroupOverviewImages + editOpportunity?: MusicalGroupOverviewEditOpportunity + related?: MusicalGroupOverviewRelated + snippet?: MusicalGroupOverviewSnippet + latestEdit?: HomeRecentChange + infobox?: MusicalGroupInfobox + noEnglishArticle?: boolean + fetchedAt: number +} + +export interface MusicalGroupData { + id: string + label: string + isMusicPerformer: boolean + isLocation: boolean + isPerson: boolean + /** Intro carousel: performers/locations always; people when creative, performing, or sports. */ + showImageCarousel?: boolean + description?: string + typeLabel?: string + inceptionYear?: number + yearKind?: YearKind + genres: string[] + country?: string + population?: number + websiteUrl?: string + websiteHost?: string + images: CarouselImage[] + editIndicator?: EditIndicator + enwikiTitle?: string + commonsCategory?: string + /** Wikidata P18 filename, used to seed Commons image ordering. */ + imageFilename?: string + commonsImageCount?: number + commonsImageCountCapped?: boolean +} + +/** True when the item has Commons photos worth a dedicated Images tab. */ +export function hasImagesTab(data: MusicalGroupData): boolean { + if (data.images.length > 0) return true + if (data.imageFilename) return true + if ((data.commonsImageCount ?? 0) > 0) return true + return false +} + +/** Featured "article of the day" card. */ +export interface HomeFeatured { + title: string + enwikiTitle: string + description: string + thumbnailUrl?: string + articleUrl: string + itemId?: string +} + +/** A "Did you know" hook from the daily featured feed. */ +export interface HomeDidYouKnow { + text: string + /** Primary hook subject to bold within {@link text}. */ + emphasis?: string + enwikiTitle?: string + title?: string + thumbnailUrl?: string + articleUrl?: string + itemId?: string +} + +/** A birthday entry from on-this-day births. */ +export interface HomeBornOnThisDay { + year: number + text: string + title: string + enwikiTitle: string + thumbnailUrl?: string + articleUrl: string + itemId?: string +} + +export interface HomeFeaturedTab { + article?: HomeFeatured + didYouKnow: HomeDidYouKnow[] + bornOnThisDay: HomeBornOnThisDay[] +} + +/** A bookmarked item resolved to display + lookup metadata. */ +export interface HomeSavedItem { + id: string + title: string + enwikiTitle?: string + description: string + thumbnailUrl?: string + savedAt: number +} + +/** A "Help wanted" edit suggestion for a saved or recommended page. */ +export interface HomeHelpWanted { + itemId: string + /** Suggestion label, e.g. "Find a reference". */ + suggestionLabel: string + /** Article/page label. */ + title: string + /** Suggestion copy. */ + body: string + need: string + enwikiTitle?: string + thumbnailUrl?: string + /** Display title of the saved page this suggestion is related to. */ + relatedToTitle: string +} + +/** A "Related reading" recommendation from a morelike query. */ +export interface HomeRelated { + title: string + description: string + thumbnailUrl?: string + articleUrl: string + itemId?: string + /** Display title of the page this recommendation was seeded from. */ + relatedToTitle: string +} + +/** A most-read article from the daily featured feed. */ +export interface HomeTrending { + title: string + enwikiTitle: string + description: string + thumbnailUrl?: string + articleUrl: string + itemId?: string + viewCount: number + viewsLabel: string + lastEditedTimestamp: string + lastEditedLabel: string + rank?: number +} + +export type HomeRecentChangeFlag = + | 'first-edit' + | 'new-editor' + | 'good-faith' + | 'needs-reference' + | 'tone-issue' + | 'high-revert-risk' + | 'none' + +/** A classified edit on a saved page, for the Activity feed. */ +export interface HomeRecentChange { + enwikiTitle: string + title: string + editSummary: string + thumbnailUrl?: string + diffUrl: string + revid: number + flag: HomeRecentChangeFlag + reverted: boolean + /** True when this revision is still the current tip of the article. */ + isLatest: boolean + editedTimestamp: string + /** e.g. "2 hours ago by SomeUser" */ + editedLabel: string +} + +/** Flags for positive edits that can receive a thank action. */ +export type ThankableEditFlag = 'first-edit' | 'new-editor' | 'good-faith' + +export function isThankableEditFlag(flag: HomeRecentChangeFlag): flag is ThankableEditFlag { + return flag === 'first-edit' || flag === 'new-editor' || flag === 'good-faith' +} + +export interface FetchMusicalGroupOptions { + signal?: AbortSignal + /** + * Called once with a partial record as soon as Stage 0 (claims + + * classification) resolves, before images / genres / edit indicator stream + * in. Lets the UI render the title + facts immediately. + */ + onPartial?: (data: MusicalGroupData) => void +} + +export interface FetchMusicalGroupResult { + data: MusicalGroupData + commonsImageCount?: number + commonsImageCountCapped?: boolean +} diff --git a/src/prototypes/musical-group/data/wikidataApi.ts b/src/prototypes/musical-group/data/wikidataApi.ts new file mode 100644 index 0000000..785383d --- /dev/null +++ b/src/prototypes/musical-group/data/wikidataApi.ts @@ -0,0 +1,712 @@ +import { wikimediaApiFetchHeaders } from '@/config' + +import { fetchWikimedia } from '@/lib/fetchWikimedia' +import { entityDisplayLabel, sentenceCase } from './formatLabel' +import { + LOCATION_QIDS, + MUSIC_PERFORMER_QIDS, + PERSON_QIDS, + type EditIndicator, + type MusicalGroupSearchResult, + type YearKind, +} from './types' + +function musicPerformerValuesClause(variable = '?anchor'): string { + const values = MUSIC_PERFORMER_QIDS.map((qid) => `wd:${qid}`).join(' ') + return `VALUES ${variable} { ${values} }` +} + +function locationValuesClause(variable = '?anchor'): string { + const values = LOCATION_QIDS.map((qid) => `wd:${qid}`).join(' ') + return `VALUES ${variable} { ${values} }` +} + +function personValuesClause(variable = '?anchor'): string { + const values = PERSON_QIDS.map((qid) => `wd:${qid}`).join(' ') + return `VALUES ${variable} { ${values} }` +} + +function musicPerformerMatchClause(subject: string): string { + return `{ + ${subject} wdt:P31/wdt:P279* ?anchor . + ${musicPerformerValuesClause('?anchor')} +} UNION { + ${subject} wdt:P106/wdt:P279* ?anchor . + ${musicPerformerValuesClause('?anchor')} +}` +} + +const WIKIDATA_API = 'https://www.wikidata.org/w/api.php' +const WIKIDATA_SPARQL = 'https://query.wikidata.org/sparql' + +function actionUrl(params: Record): string { + const search = new URLSearchParams({ + format: 'json', + formatversion: '2', + origin: '*', + ...params, + }) + return `${WIKIDATA_API}?${search.toString()}` +} + +async function sparqlQuery(query: string, signal?: AbortSignal): Promise { + const url = `${WIKIDATA_SPARQL}?query=${encodeURIComponent(query)}` + const response = await fetchWikimedia(url, { + signal, + headers: { + Accept: 'application/sparql-results+json', + ...wikimediaApiFetchHeaders('musical-group-sparql'), + }, + }) + if (!response.ok) { + throw new Error(`SPARQL request failed (${response.status})`) + } + return response.json() as Promise +} + +export function normalizeQid(raw: unknown): string | null { + if (typeof raw !== 'string') return null + const trimmed = raw.trim() + const match = trimmed.match(/^Q(\d+)$/i) + if (!match) return null + return `Q${match[1]}` +} + +export function parseQidInput(raw: string): string | null { + const trimmed = raw.trim() + const direct = normalizeQid(trimmed) + if (direct) return direct + const urlMatch = trimmed.match(/wikidata\.org\/wiki\/(Q\d+)/i) + if (urlMatch) return normalizeQid(urlMatch[1]) + return null +} + +export interface EntityClassification { + isMusicPerformer: boolean + isLocation: boolean + isPerson: boolean + isHuman: boolean + musicTypeLabel?: string + locationTypeLabel?: string +} + +interface ClassificationSparqlRow { + perfType?: { value: string } + perfTypeLabel?: { value: string } + locType?: { value: string } + locTypeLabel?: { value: string } + locAnchor?: { value: string } + humanType?: { value: string } +} + +/** + * Classify an entity as a music performer and/or a location, and resolve the + * best display label for each, in a single WDQS round-trip. Replaces the + * separate `isMusicPerformer` / `isLocation` ASK queries and the + * `resolveMusicTypeLabel` / `resolveLocationTypeLabel` SELECT queries. + */ +export async function classifyEntity( + id: string, + signal?: AbortSignal, +): Promise { + const sparql = ` +SELECT ?perfType ?perfTypeLabel ?locType ?locTypeLabel ?locAnchor ?humanType WHERE { + OPTIONAL { + { + wd:${id} wdt:P31 ?perfType . + ?perfType wdt:P279* ?pa . + ${musicPerformerValuesClause('?pa')} + } UNION { + wd:${id} wdt:P106 ?perfType . + ?perfType wdt:P279* ?pa . + ${musicPerformerValuesClause('?pa')} + } + } + OPTIONAL { + wd:${id} wdt:P31 ?locType . + ?locType wdt:P279* ?locAnchor . + ${locationValuesClause('?locAnchor')} + } + OPTIONAL { + wd:${id} wdt:P31 ?humanType . + ?humanType wdt:P279* ?ha . + ${personValuesClause('?ha')} + } + SERVICE wikibase:label { bd:serviceParam wikibase:language "en". } +}` + + const data = await sparqlQuery<{ results: { bindings: ClassificationSparqlRow[] } }>( + sparql, + signal, + ) + const bindings = data.results.bindings + + const performerLabels: string[] = [] + const locationEntries: { anchor: string; label: string }[] = [] + + for (const row of bindings) { + if (row.perfType && row.perfTypeLabel?.value) { + performerLabels.push(row.perfTypeLabel.value) + } + if (row.locType && row.locAnchor) { + locationEntries.push({ + anchor: row.locAnchor.value.replace(/^.*\//, ''), + label: row.locTypeLabel?.value ?? '', + }) + } + } + + const isMusicPerformer = performerLabels.length > 0 + const isLocation = locationEntries.length > 0 + const isHuman = bindings.some((row) => row.humanType) + const isPerson = isHuman && !isMusicPerformer && !isLocation + + // Longest label wins — matches the old resolveMusicTypeLabel heuristic. + const musicTypeLabel = isMusicPerformer + ? [...performerLabels].sort((a, b) => b.length - a.length)[0] + : undefined + + // Prefer the earliest anchor in LOCATION_QIDS priority order. + let locationTypeLabel: string | undefined + if (isLocation) { + for (const anchorQid of LOCATION_QIDS) { + const match = locationEntries.find((entry) => entry.anchor === anchorQid && entry.label) + if (match) { + locationTypeLabel = match.label + break + } + } + if (!locationTypeLabel) { + locationTypeLabel = locationEntries.find((entry) => entry.label)?.label + } + } + + return { isMusicPerformer, isLocation, isPerson, isHuman, musicTypeLabel, locationTypeLabel } +} + +interface EntitySearchSparqlRow { + item: { value: string } + itemLabel: { value: string } + itemDescription?: { value: string } + image?: { value: string } + enwikiTitle?: { value: string } +} + +function parseEntitySearchResults( + bindings: EntitySearchSparqlRow[], +): MusicalGroupSearchResult[] { + const seen = new Set() + const results: MusicalGroupSearchResult[] = [] + + for (const row of bindings) { + const id = row.item.value.replace(/^.*\//, '') + if (seen.has(id)) continue + seen.add(id) + + const rawImage = row.image?.value + results.push({ + id, + label: entityDisplayLabel(row.itemLabel.value, row.enwikiTitle?.value), + description: row.itemDescription?.value + ? sentenceCase(row.itemDescription.value) + : undefined, + thumbnailUrl: rawImage ? `${rawImage}?width=256` : undefined, + }) + } + + return results +} + +function entitySearchSparql(escaped: string, filterClause?: string): string { + const filter = filterClause ? `\n ${filterClause}` : '' + return ` +SELECT ?item ?itemLabel ?itemDescription ?image ?enwikiTitle WHERE { + SERVICE wikibase:mwapi { + bd:serviceParam wikibase:endpoint "www.wikidata.org"; + wikibase:api "EntitySearch"; + mwapi:search "${escaped}"; + mwapi:language "en". + ?item wikibase:apiOutputItem "@id". + }${filter} + OPTIONAL { ?item wdt:P18 ?image } + OPTIONAL { + ?enwikiArticle schema:about ?item ; + schema:isPartOf ; + schema:name ?enwikiTitle . + } + SERVICE wikibase:label { bd:serviceParam wikibase:language "en". } +} +LIMIT 8` +} + +interface WbSearchEntityHit { + id: string + label: string + description?: string +} + +interface WbSearchEntitiesResponse { + search?: WbSearchEntityHit[] +} + +export async function searchWikidataItems( + searchText: string, + signal?: AbortSignal, +): Promise { + const query = searchText.trim() + if (!query.length) return [] + + // wbsearchentities ranks exact matches first (e.g. "water" → Q283). SPARQL + // EntitySearch via mwapi does not and often omits the primary item entirely. + const searchUrl = actionUrl({ + action: 'wbsearchentities', + search: query, + language: 'en', + limit: '8', + type: 'item', + }) + const searchResponse = await fetchWikimedia(searchUrl, { + signal, + headers: wikimediaApiFetchHeaders('musical-group-wbsearchentities'), + }) + if (!searchResponse.ok) { + throw new Error(`wbsearchentities failed (${searchResponse.status})`) + } + + const searchData = (await searchResponse.json()) as WbSearchEntitiesResponse + const hits = searchData.search ?? [] + if (!hits.length) return [] + + const entitiesUrl = actionUrl({ + action: 'wbgetentities', + ids: hits.map((hit) => hit.id).join('|'), + props: 'claims|sitelinks', + }) + const entitiesResponse = await fetchWikimedia(entitiesUrl, { + signal, + headers: wikimediaApiFetchHeaders('musical-group-wbsearchentities-enrich'), + }) + if (!entitiesResponse.ok) { + throw new Error(`wbgetentities failed (${entitiesResponse.status})`) + } + + const entitiesData = (await entitiesResponse.json()) as WbGetEntitiesResponse + const entities = entitiesData.entities ?? {} + + return hits.map((hit) => { + const entity = entities[hit.id] + const enwikiTitle = entity?.sitelinks?.enwiki?.title + const imageFilename = firstClaimString(entity?.claims?.P18) + return { + id: hit.id, + label: entityDisplayLabel(hit.label, enwikiTitle), + description: hit.description ? sentenceCase(hit.description) : undefined, + thumbnailUrl: imageFilename ? commonsFileUrl(imageFilename, 256) : undefined, + } + }) +} + +export async function searchMusicPerformers( + searchText: string, + signal?: AbortSignal, +): Promise { + const query = searchText.trim() + if (!query.length) return [] + + const escaped = query.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + const sparql = entitySearchSparql(escaped, musicPerformerMatchClause('?item')) + const data = await sparqlQuery<{ results: { bindings: EntitySearchSparqlRow[] } }>( + sparql, + signal, + ) + return parseEntitySearchResults(data.results.bindings) +} + +interface WbEntityClaim { + mainsnak: { + datavalue?: { + type: string + value: string | { id?: string; time?: string; text?: string } + } + } + rank?: string + qualifiers?: Record< + string, + Array<{ + datavalue?: { + type: string + value: string | { id?: string; time?: string; text?: string } + } + }> + > +} + +interface WbEntity { + id?: string + labels?: Record + descriptions?: Record + claims?: Record + sitelinks?: Record +} + +interface WbGetEntitiesResponse { + entities?: Record +} + +export interface ParsedEntityClaims { + label: string + description?: string + imageFilename?: string + commonsCategory?: string + enwikiTitle?: string + websiteUrl?: string + inceptionYear?: number + yearKind?: YearKind + genreIds: string[] + /** Non-deprecated P106 values, preferred rank first. */ + occupationIds: string[] + /** P106 values marked preferred in Wikidata (subset of {@link occupationIds}). */ + preferredOccupationIds: string[] + typeIds: string[] + countryId?: string + population?: number +} + +function claimEntityId(claim: WbEntityClaim): string | null { + const value = claim.mainsnak.datavalue?.value + if (typeof value === 'object' && value && 'id' in value && value.id) { + return value.id + } + return null +} + +function claimStringValue(claim: WbEntityClaim): string | null { + const value = claim.mainsnak.datavalue?.value + if (typeof value === 'string') return value + if (typeof value === 'object' && value && 'text' in value && value.text) { + return value.text + } + return null +} + +function claimTimeYear(claim: WbEntityClaim): number | null { + const value = claim.mainsnak.datavalue?.value + if (typeof value !== 'object' || !value || !('time' in value) || !value.time) return null + const match = value.time.match(/^\+?(-?\d{4})/) + if (!match) return null + return Number.parseInt(match[1], 10) +} + +function claimQuantityAmount(claim: WbEntityClaim): number | null { + const value = claim.mainsnak.datavalue?.value + if (typeof value !== 'object' || !value || !('amount' in value)) return null + const amount = (value as { amount?: string }).amount + if (!amount) return null + const num = Number.parseFloat(amount.replace(/^\+/, '')) + if (!Number.isFinite(num)) return null + return Math.round(num) +} + +function firstClaimString(claims: WbEntityClaim[] | undefined): string | undefined { + if (!claims?.length) return undefined + for (const claim of claims) { + const value = claimStringValue(claim) + if (value) return value + } + return undefined +} + +function allClaimEntityIds(claims: WbEntityClaim[] | undefined): string[] { + if (!claims?.length) return [] + const ids: string[] = [] + for (const claim of claims) { + const id = claimEntityId(claim) + if (id) ids.push(id) + } + return ids +} + +/** P106 claim values in Wikidata order, preferred rank first, deprecated omitted. */ +function orderedOccupationClaimIds(claims: WbEntityClaim[] | undefined): { + occupationIds: string[] + preferredOccupationIds: string[] +} { + if (!claims?.length) { + return { occupationIds: [], preferredOccupationIds: [] } + } + + const active = claims.filter((claim) => claim.rank !== 'deprecated') + const pool = active.length ? active : claims + const preferredOccupationIds: string[] = [] + const normalOccupationIds: string[] = [] + + for (const claim of pool) { + const id = claimEntityId(claim) + if (!id) continue + if (claim.rank === 'preferred') preferredOccupationIds.push(id) + else normalOccupationIds.push(id) + } + + return { + occupationIds: [...preferredOccupationIds, ...normalOccupationIds], + preferredOccupationIds, + } +} + +function claimQualifierTime(claim: WbEntityClaim, property: string): string | null { + const qual = claim.qualifiers?.[property]?.[0] + const value = qual?.datavalue?.value + if (typeof value === 'object' && value && 'time' in value && value.time) { + return value.time + } + return null +} + +function compareWikidataTime(a: string | null, b: string | null): number { + if (a == null && b == null) return 0 + if (a == null) return 1 + if (b == null) return -1 + return a.localeCompare(b) +} + +/** Pick the current or most recent value from a temporally qualified claim list (e.g. P17 country). */ +function latestClaimEntityId(claims: WbEntityClaim[] | undefined): string | undefined { + if (!claims?.length) return undefined + + const pool = claims.filter((claim) => claim.rank !== 'deprecated') + const candidates = pool.length ? pool : claims + + const withEntity = candidates + .map((claim) => ({ claim, id: claimEntityId(claim) })) + .filter((entry): entry is { claim: WbEntityClaim; id: string } => Boolean(entry.id)) + + if (!withEntity.length) return undefined + + const current = withEntity.filter(({ claim }) => !claimQualifierTime(claim, 'P582')) + + if (current.length) { + const preferred = current.find(({ claim }) => claim.rank === 'preferred') + if (preferred) return preferred.id + + return current.reduce((best, entry) => + compareWikidataTime( + claimQualifierTime(best.claim, 'P580'), + claimQualifierTime(entry.claim, 'P580'), + ) <= 0 + ? entry + : best, + ).id + } + + return withEntity.reduce((best, entry) => + compareWikidataTime( + claimQualifierTime(best.claim, 'P582'), + claimQualifierTime(entry.claim, 'P582'), + ) <= 0 + ? entry + : best, + ).id +} + +export async function fetchEntityClaims( + id: string, + signal?: AbortSignal, +): Promise { + const url = actionUrl({ + action: 'wbgetentities', + ids: id, + props: 'labels|descriptions|claims|sitelinks', + languages: 'en', + languagefallback: '1', + }) + + const response = await fetchWikimedia(url, { + signal, + headers: wikimediaApiFetchHeaders('musical-group-wbgetentities'), + }) + if (!response.ok) { + throw new Error(`wbgetentities failed (${response.status})`) + } + + const data = (await response.json()) as WbGetEntitiesResponse + const entity = data.entities?.[id] + if (!entity) { + throw new Error(`Entity ${id} not found`) + } + + const label = + entity.labels?.en?.value ?? + Object.values(entity.labels ?? {})[0]?.value ?? + id + const rawDescription = + entity.descriptions?.en?.value ?? Object.values(entity.descriptions ?? {})[0]?.value + const description = rawDescription ? sentenceCase(rawDescription) : undefined + + const claims = entity.claims ?? {} + const inceptionYear = claims.P571?.length ? claimTimeYear(claims.P571[0]) : null + const birthYear = claims.P569?.length ? claimTimeYear(claims.P569[0]) : null + + let year: number | undefined + let yearKind: YearKind | undefined + if (inceptionYear != null) { + year = inceptionYear + yearKind = 'inception' + } else if (birthYear != null) { + year = birthYear + yearKind = 'birth' + } + + return { + label, + description, + imageFilename: firstClaimString(claims.P18), + commonsCategory: firstClaimString(claims.P373), + enwikiTitle: entity.sitelinks?.enwiki?.title, + websiteUrl: firstClaimString(claims.P856), + inceptionYear: year, + yearKind, + genreIds: allClaimEntityIds(claims.P136), + ...orderedOccupationClaimIds(claims.P106), + typeIds: allClaimEntityIds(claims.P31), + countryId: latestClaimEntityId(claims.P17), + population: claims.P1082?.length ? claimQuantityAmount(claims.P1082[0]) ?? undefined : undefined, + } +} + +export async function resolveEntityLabels( + ids: string[], + signal?: AbortSignal, +): Promise> { + const unique = [...new Set(ids.filter(Boolean))] + const labels = new Map() + if (!unique.length) return labels + + const url = actionUrl({ + action: 'wbgetentities', + ids: unique.join('|'), + props: 'labels', + languages: 'en', + languagefallback: '1', + }) + + const response = await fetchWikimedia(url, { + signal, + headers: wikimediaApiFetchHeaders('musical-group-wbgetentities-labels'), + }) + if (!response.ok) { + throw new Error(`wbgetentities labels failed (${response.status})`) + } + + const data = (await response.json()) as WbGetEntitiesResponse + for (const entityId of unique) { + const entity = data.entities?.[entityId] + const label = + entity?.labels?.en?.value ?? + Object.values(entity?.labels ?? {})[0]?.value ?? + entityId + labels.set(entityId, label) + } + return labels +} + +export async function fetchEditIndicator( + id: string, + signal?: AbortSignal, +): Promise { + const url = actionUrl({ + action: 'query', + prop: 'revisions', + rvprop: 'timestamp', + rvlimit: '1', + titles: `${id}|Talk:${id}`, + }) + + const response = await fetchWikimedia(url, { + signal, + headers: wikimediaApiFetchHeaders('musical-group-revisions'), + }) + if (!response.ok) { + throw new Error(`revisions query failed (${response.status})`) + } + + interface RevisionPage { + title: string + revisions?: { timestamp?: string }[] + } + + interface RevisionsResponse { + query?: { pages?: RevisionPage[] } + } + + const data = (await response.json()) as RevisionsResponse + const pages = data.query?.pages ?? [] + + let itemTimestamp: string | undefined + let talkTimestamp: string | undefined + + for (const page of pages) { + const timestamp = page.revisions?.[0]?.timestamp + if (!timestamp) continue + if (page.title === id) itemTimestamp = timestamp + if (page.title === `Talk:${id}`) talkTimestamp = timestamp + } + + if (!itemTimestamp && !talkTimestamp) return undefined + if (!itemTimestamp) return 'talk' + if (!talkTimestamp) return 'history' + + return talkTimestamp > itemTimestamp ? 'talk' : 'history' +} + +export function commonsFileUrl(filename: string, width = 640): string { + const name = filename.replace(/^File:/i, '').trim().replace(/ /g, '_') + return `https://commons.wikimedia.org/wiki/Special:FilePath/${encodeURIComponent(name)}?width=${width}` +} + +export function normalizeFileTitle(title: string): string { + return title.replace(/^File:/i, '').trim().toLowerCase() +} + +export function fileTitleFromInput(title: string): string { + return title.replace(/^File:/i, '').trim() +} + +export function websiteHost(url: string): string { + try { + return new URL(url).hostname.replace(/^www\./, '') + } catch { + return url.replace(/^https?:\/\//, '').replace(/\/.*$/, '') + } +} + +/** Host + path for external link labels, e.g. `instagram.com/jadethirlwall`. */ +export function externalLinkLabel(url: string): string { + try { + const parsed = new URL(url) + const host = parsed.hostname.replace(/^www\./, '') + let path = parsed.pathname.replace(/\/+$/, '') + if (!path || path === '/') return host + return `${host}${path}` + } catch { + return url.replace(/^https?:\/\//, '') + } +} + +export function wikidataHistoryUrl(id: string): string { + return `https://www.wikidata.org/w/index.php?title=${encodeURIComponent(id)}&action=history` +} + +export function wikidataTalkHistoryUrl(id: string): string { + return `https://www.wikidata.org/w/index.php?title=${encodeURIComponent(`Talk:${id}`)}&action=history` +} + +export function wikidataEditEntityUrl(id: string): string { + return `https://www.wikidata.org/wiki/Special:EditEntity/${encodeURIComponent(id)}` +} + +export function enwikiVisualEditorUrl(title: string): string { + return `https://en.wikipedia.org/w/index.php?${new URLSearchParams({ + title: title.replace(/ /g, '_'), + veaction: 'edit', + })}` +} diff --git a/src/prototypes/musical-group/data/wikitaCache.ts b/src/prototypes/musical-group/data/wikitaCache.ts new file mode 100644 index 0000000..e6af90b --- /dev/null +++ b/src/prototypes/musical-group/data/wikitaCache.ts @@ -0,0 +1,81 @@ +export interface VersionedStore { + version: number + entries: Record +} + +export function readVersionedStore( + storageKey: string, + version: number, + isValidEntry: (entry: unknown) => entry is TEntry, +): Record { + if (typeof window === 'undefined') return {} + + try { + const raw = window.localStorage.getItem(storageKey) + if (!raw) return {} + + const parsed = JSON.parse(raw) as VersionedStore + if (parsed.version !== version || typeof parsed.entries !== 'object' || parsed.entries === null) { + return {} + } + + const entries: Record = {} + for (const [key, entry] of Object.entries(parsed.entries)) { + if (isValidEntry(entry)) entries[key] = entry + } + return entries + } catch { + return {} + } +} + +export function writeVersionedStore( + storageKey: string, + version: number, + entries: Record, +): void { + if (typeof window === 'undefined') return + + try { + if (Object.keys(entries).length === 0) { + window.localStorage.removeItem(storageKey) + return + } + const payload: VersionedStore = { version, entries } + window.localStorage.setItem(storageKey, JSON.stringify(payload)) + } catch { + // Quota or private-mode failures — ignore. + } +} + +export function getVersionedEntry( + storageKey: string, + version: number, + key: string, + isValidEntry: (entry: unknown) => entry is TEntry, +): TEntry | null { + return readVersionedStore(storageKey, version, isValidEntry)[key] ?? null +} + +export function setVersionedEntry( + storageKey: string, + version: number, + key: string, + entry: TEntry, + isValidEntry: (entry: unknown) => entry is TEntry, +): void { + const entries = readVersionedStore(storageKey, version, isValidEntry) + entries[key] = entry + writeVersionedStore(storageKey, version, entries) +} + +export function removeVersionedEntry( + storageKey: string, + version: number, + key: string, + isValidEntry: (entry: unknown) => boolean, +): void { + const entries = readVersionedStore(storageKey, version, isValidEntry) + delete entries[key] + writeVersionedStore(storageKey, version, entries) +} diff --git a/src/prototypes/musical-group/data/wikitaChromeHeaderVariants.ts b/src/prototypes/musical-group/data/wikitaChromeHeaderVariants.ts new file mode 100644 index 0000000..d08bb34 --- /dev/null +++ b/src/prototypes/musical-group/data/wikitaChromeHeaderVariants.ts @@ -0,0 +1,188 @@ +import type { WikitaChromeHeaderVariant } from './headerVariantPreference' +import { wikitaColor } from './wikitaPalette' + +const INVERTED_FG = 'var(--color-inverted-fixed)' + +export type WikitaChromeHeaderVariantStyle = { + bg: string + border: string + fg: string + /** Light backgrounds use a dark translucent hover overlay. */ + lightHover: boolean +} + +export const WIKITA_CHROME_HEADER_VARIANT_STYLES: Record< + WikitaChromeHeaderVariant, + WikitaChromeHeaderVariantStyle +> = { + black: { + bg: 'var(--background-color-inverted)', + border: wikitaColor('gray', 800), + fg: INVERTED_FG, + lightHover: false, + }, + 'off-black': { + bg: wikitaColor('gray', 800), + border: 'var(--color-base)', + fg: INVERTED_FG, + lightHover: false, + }, + gray: { + bg: wikitaColor('gray', 50), + border: wikitaColor('gray', 200), + fg: wikitaColor('gray', 500), + lightHover: true, + }, + 'gray-bold': { + bg: wikitaColor('gray', 500), + border: wikitaColor('gray', 600), + fg: INVERTED_FG, + lightHover: false, + }, + 'red-light': { + bg: wikitaColor('red', 200), + border: wikitaColor('red', 300), + fg: wikitaColor('red', 500), + lightHover: true, + }, + 'red-dark': { + bg: wikitaColor('red', 500), + border: wikitaColor('red', 600), + fg: INVERTED_FG, + lightHover: false, + }, + 'orange-light': { + bg: wikitaColor('orange', 100), + border: wikitaColor('orange', 200), + fg: wikitaColor('orange', 400), + lightHover: true, + }, + 'orange-dark': { + bg: wikitaColor('orange', 600), + border: wikitaColor('orange', 700), + fg: INVERTED_FG, + lightHover: false, + }, + 'brown-light': { + bg: wikitaColor('orange', 400), + border: wikitaColor('orange', 500), + fg: wikitaColor('orange', 800), + lightHover: true, + }, + 'orange-bold': { + bg: wikitaColor('orange', 400), + border: wikitaColor('orange', 300), + fg: INVERTED_FG, + lightHover: false, + }, + 'yellow-light': { + bg: wikitaColor('yellow', 50), + border: wikitaColor('yellow', 100), + fg: wikitaColor('yellow', 400), + lightHover: true, + }, + 'yellow-bold': { + bg: wikitaColor('yellow', 200), + border: wikitaColor('yellow', 300), + fg: wikitaColor('gray', 900), + lightHover: true, + }, + 'lime-light': { + bg: wikitaColor('lime', 100), + border: wikitaColor('lime', 200), + fg: wikitaColor('lime', 500), + lightHover: true, + }, + 'lime-bold': { + bg: wikitaColor('lime', 400), + border: wikitaColor('lime', 500), + fg: INVERTED_FG, + lightHover: false, + }, + 'green-light': { + bg: wikitaColor('green', 200), + border: wikitaColor('green', 300), + fg: wikitaColor('green', 600), + lightHover: true, + }, + 'green-dark': { + bg: wikitaColor('green', 400), + border: wikitaColor('green', 500), + fg: INVERTED_FG, + lightHover: false, + }, + 'blue-light': { + bg: wikitaColor('blue', 300), + border: wikitaColor('blue', 400), + fg: wikitaColor('blue', 600), + lightHover: true, + }, + 'blue-bold': { + bg: wikitaColor('blue', 500), + border: wikitaColor('blue', 600), + fg: INVERTED_FG, + lightHover: false, + }, + 'purple-light': { + bg: wikitaColor('purple', 300), + border: wikitaColor('purple', 400), + fg: wikitaColor('purple', 700), + lightHover: true, + }, + 'purple-bold': { + bg: wikitaColor('purple', 500), + border: wikitaColor('purple', 600), + fg: INVERTED_FG, + lightHover: false, + }, + 'pink-light': { + bg: wikitaColor('pink', 300), + border: wikitaColor('pink', 400), + fg: wikitaColor('pink', 800), + lightHover: true, + }, + 'pink-bold': { + bg: wikitaColor('pink', 500), + border: wikitaColor('pink', 600), + fg: INVERTED_FG, + lightHover: false, + }, + 'maroon-light': { + bg: wikitaColor('maroon', 300), + border: wikitaColor('maroon', 400), + fg: wikitaColor('maroon', 600), + lightHover: true, + }, + 'maroon-bold': { + bg: wikitaColor('maroon', 500), + border: wikitaColor('maroon', 600), + fg: INVERTED_FG, + lightHover: false, + }, +} + +export const WIKITA_CHROME_HEADER_LIGHT_HOVER_BG = 'rgba(0, 0, 0, 0.08)' +export const WIKITA_CHROME_HEADER_BOLD_HOVER_BG = 'rgba(255, 255, 255, 0.12)' + +export type HeaderVariantMenuItemStyle = { + lightHover: boolean + style: { + backgroundColor: string + borderBottom: string + color: string + } +} + +export function headerVariantMenuItemStyle( + variant: WikitaChromeHeaderVariant, +): HeaderVariantMenuItemStyle { + const colors = WIKITA_CHROME_HEADER_VARIANT_STYLES[variant] + return { + lightHover: colors.lightHover, + style: { + backgroundColor: colors.bg, + borderBottom: `2px solid ${colors.border}`, + color: colors.fg, + }, + } +} diff --git a/src/prototypes/musical-group/data/wikitaPalette.ts b/src/prototypes/musical-group/data/wikitaPalette.ts new file mode 100644 index 0000000..d40d41d --- /dev/null +++ b/src/prototypes/musical-group/data/wikitaPalette.ts @@ -0,0 +1,160 @@ +/** + * Wikita color palette from Figma. + * @see https://www.figma.com/design/Nfwe0U9z59oR4CpYO810wF/Wikita?node-id=83-864 + */ +export const WIKITA_PALETTE_STEPS = [ + 50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 1000, +] as const + +export type WikitaPaletteStep = (typeof WIKITA_PALETTE_STEPS)[number] + +export type WikitaPaletteFamily = + | 'gray' + | 'red' + | 'orange' + | 'yellow' + | 'lime' + | 'green' + | 'blue' + | 'purple' + | 'pink' + | 'maroon' + +export type WikitaPalette = Record> + +export const WIKITA_PALETTE: WikitaPalette = { + gray: { + 50: '#f3f3f3', + 100: '#ebebeb', + 200: '#dbdbdb', + 300: '#ccc', + 400: '#a8a8a8', + 500: '#787878', + 600: '#595959', + 700: '#424242', + 800: '#292929', + 900: '#212121', + 1000: '#171717', + }, + red: { + 50: '#ffeeeb', + 100: '#ffe1db', + 200: '#ffc8bd', + 300: '#fea998', + 400: '#ff7357', + 500: '#ff2b00', + 600: '#e02500', + 700: '#c92200', + 800: '#ad1d00', + 900: '#6b1200', + 1000: '#470c00', + }, + orange: { + 50: '#ffede0', + 100: '#ffe1cc', + 200: '#ffc8a1', + 300: '#ffa666', + 400: '#ff7a1a', + 500: '#db5b00', + 600: '#c75300', + 700: '#ad4800', + 800: '#963f00', + 900: '#5c2600', + 1000: '#3b1800', + }, + yellow: { + 50: '#fff0c9', + 100: '#ffe49c', + 200: '#ffcf4f', + 300: '#f2b200', + 400: '#cf9700', + 500: '#a87b00', + 600: '#997000', + 700: '#856100', + 800: '#735400', + 900: '#453200', + 1000: '#2e2200', + }, + lime: { + 50: '#b8ffcd', + 100: '#85ffa9', + 200: '#00f249', + 300: '#00de43', + 400: '#00ba38', + 500: '#00992e', + 600: '#008a29', + 700: '#007824', + 800: '#00691f', + 900: '#004013', + 1000: '#002b0d', + }, + green: { + 50: '#c7ffee', + 100: '#76ffd6', + 200: '#00f3aa', + 300: '#00db9a', + 400: '#00b881', + 500: '#009669', + 600: '#00875f', + 700: '#007552', + 800: '#006647', + 900: '#003d2b', + 1000: '#002b1e', + }, + blue: { + 50: '#edf3ff', + 100: '#dbe7ff', + 200: '#c2d6ff', + 300: '#a3c2ff', + 400: '#709fff', + 500: '#3d7dff', + 600: '#246cff', + 700: '#0052f5', + 800: '#0049db', + 900: '#002d87', + 1000: '#001f5e', + }, + purple: { + 50: '#f6f0ff', + 100: '#eee3ff', + 200: '#e0ccff', + 300: '#d1b2ff', + 400: '#b787ff', + 500: '#9f5eff', + 600: '#934aff', + 700: '#812bff', + 800: '#6700ff', + 900: '#4100a1', + 1000: '#2c006e', + }, + pink: { + 50: '#ffedfa', + 100: '#ffdef6', + 200: '#ffc2ee', + 300: '#ffa1e5', + 400: '#ff63d4', + 500: '#f500b1', + 600: '#e000a3', + 700: '#c2008d', + 800: '#a8007a', + 900: '#6b004e', + 1000: '#470034', + }, + maroon: { + 50: '#ffedf1', + 100: '#ffe0e8', + 200: '#ffc7d5', + 300: '#ffa6bd', + 400: '#ff6e93', + 500: '#ff215a', + 600: '#f0003d', + 700: '#cf0035', + 800: '#b2002e', + 900: '#70001d', + 1000: '#4d0014', + }, +} + +export function wikitaColor(family: WikitaPaletteFamily, step: WikitaPaletteStep): string { + return WIKITA_PALETTE[family][step] +} diff --git a/src/prototypes/musical-group/index.vue b/src/prototypes/musical-group/index.vue new file mode 100644 index 0000000..22c1325 --- /dev/null +++ b/src/prototypes/musical-group/index.vue @@ -0,0 +1,449 @@ + + + + + + + + + + + diff --git a/src/prototypes/musical-group/musicalGroupScrollOffset.ts b/src/prototypes/musical-group/musicalGroupScrollOffset.ts new file mode 100644 index 0000000..0e26f81 --- /dev/null +++ b/src/prototypes/musical-group/musicalGroupScrollOffset.ts @@ -0,0 +1,125 @@ +import { scrollTabIntoTrackView } from './scrollTabIntoTrackView' + +export function getMusicalGroupScrollPage(): HTMLElement | null { + return document.querySelector('.musical-group-page') +} + +export function scrollMusicalGroupPageToTop(behavior: ScrollBehavior = 'instant'): void { + const page = getMusicalGroupScrollPage() + if (!page) return + page.scrollTo({ top: 0, behavior }) +} + +/** Scroll a tab button into view in the home or entity tab strip. */ +export function scrollMusicalGroupTabIntoView(tabId: string): void { + const page = getMusicalGroupScrollPage() + if (!page) return + + const track = page.querySelector('.musical-group-tabs__track') + if (!(track instanceof HTMLElement)) return + + const button = track.querySelector(`[data-tab-id="${tabId}"]`) + if (button) scrollTabIntoTrackView(button, track) +} + +export function isMusicalGroupTabsStuck(page: Element): boolean { + return page.hasAttribute('data-tabs-stuck') +} + +function getScrollContentOffsetTop(el: Element, scrollPage: HTMLElement): number { + const pageRect = scrollPage.getBoundingClientRect() + const elRect = el.getBoundingClientRect() + return scrollPage.scrollTop + (elRect.top - pageRect.top) +} + +/** ScrollTop at which the tab bar first pins below the chrome stack. */ +export function measureMusicalGroupTabsStuckBaseline(page: HTMLElement): number { + const tabsEl = page.querySelector('.musical-group-tabs-sticky') + if (!tabsEl) return 0 + + const stickyTop = + parseFloat(getComputedStyle(page).getPropertyValue('--musical-group-tabs-sticky-top')) || 0 + const tabsOffsetTop = getScrollContentOffsetTop(tabsEl, page) + + return Math.max(0, tabsOffsetTop - stickyTop) +} + +/** Viewport inset for the top of tab panel content (sticky chrome + tab bar). */ +export function measureMusicalGroupTabPanelTopInset(page: Element): number { + const styles = getComputedStyle(page) + const tabsTop = parseFloat(styles.getPropertyValue('--musical-group-tabs-sticky-top')) + const tabsHeight = parseFloat(styles.getPropertyValue('--musical-group-tabs-height')) + + if (tabsTop > 0 && tabsHeight > 0) { + return tabsTop + tabsHeight + } + + const pageTop = page.getBoundingClientRect().top + let bottom = 0 + + for (const selector of ['.musical-group-chrome-stack', '.musical-group-tabs-sticky']) { + const el = page.querySelector(selector) + if (el) { + bottom = Math.max(bottom, el.getBoundingClientRect().bottom - pageTop) + } + } + + return bottom +} + +/** ScrollTop that places the top of the active tab panel below sticky chrome + tabs. */ +export function measureMusicalGroupTabContentTopScroll(page: HTMLElement): number { + const panel = page.querySelector('.musical-group-screen__panel') + const stuckBaseline = measureMusicalGroupTabsStuckBaseline(page) + if (!panel) return stuckBaseline + + const panelOffsetTop = getScrollContentOffsetTop(panel, page) + const panelTopScroll = Math.max(0, panelOffsetTop - measureMusicalGroupTabPanelTopInset(page)) + + return Math.max(stuckBaseline, panelTopScroll) +} + +/** ScrollTop that places the top of the home tab body below sticky chrome + tabs. */ +export function measureMusicalGroupHomeTabContentTopScroll(page: HTMLElement): number { + const body = page.querySelector('.musical-group-home__body') + const stuckBaseline = measureMusicalGroupTabsStuckBaseline(page) + if (!body) return stuckBaseline + + const bodyOffsetTop = getScrollContentOffsetTop(body, page) + const bodyTopScroll = Math.max(0, bodyOffsetTop - measureMusicalGroupTabPanelTopInset(page)) + + return Math.max(stuckBaseline, bodyTopScroll) +} + +/** Space to leave above in-page scroll targets so sticky chrome + tabs do not cover them. */ +export function measureMusicalGroupStickyScrollOffset(page: Element): number { + const styles = getComputedStyle(page) + const gap = parseFloat(styles.getPropertyValue('--spacing-50')) || 8 + const tabsTop = parseFloat(styles.getPropertyValue('--musical-group-tabs-sticky-top')) + const tabsHeight = parseFloat(styles.getPropertyValue('--musical-group-tabs-height')) + + if (tabsTop > 0 && tabsHeight > 0) { + return tabsTop + tabsHeight + gap + } + + const pageTop = page.getBoundingClientRect().top + let bottom = 0 + + for (const selector of ['.musical-group-chrome-stack', '.musical-group-tabs-sticky']) { + const el = page.querySelector(selector) + if (el) { + bottom = Math.max(bottom, el.getBoundingClientRect().bottom - pageTop) + } + } + + return bottom + gap +} + +export function scrollMusicalGroupPageToElement(page: HTMLElement, target: Element): void { + const offset = measureMusicalGroupStickyScrollOffset(page) + const pageRect = page.getBoundingClientRect() + const targetRect = target.getBoundingClientRect() + const top = page.scrollTop + (targetRect.top - pageRect.top) - offset + + page.scrollTo({ top: Math.max(0, top), behavior: 'instant' }) +} diff --git a/src/prototypes/musical-group/photosGridLayout.ts b/src/prototypes/musical-group/photosGridLayout.ts new file mode 100644 index 0000000..e7000d5 --- /dev/null +++ b/src/prototypes/musical-group/photosGridLayout.ts @@ -0,0 +1,106 @@ +import type { CarouselImage } from './data/types' + +/** Wider than 3:2 → full-width row (too landscape for half column). */ +export const FULL_WIDTH_MIN_RATIO = 3 / 2 + +export type PhotoGridRow = + | { kind: 'full'; image: CarouselImage } + | { kind: 'pair'; left: CarouselImage; right: CarouselImage } + | { kind: 'single'; image: CarouselImage } + +export function imageAspectRatio(image: CarouselImage): number { + if (image.width <= 0 || image.height <= 0) return 1 + return image.width / image.height +} + +export function isFullWidthLandscape(image: CarouselImage): boolean { + return imageAspectRatio(image) > FULL_WIDTH_MIN_RATIO +} + +export function photoCellStyle(image: CarouselImage): { aspectRatio: string } { + if (image.width > 0 && image.height > 0) { + return { aspectRatio: `${image.width} / ${image.height}` } + } + return { aspectRatio: '1' } +} + +/** At equal column width, the image with the larger width/height ratio is shorter. */ +export function shorterImageInPair(left: CarouselImage, right: CarouselImage): CarouselImage { + return imageAspectRatio(left) >= imageAspectRatio(right) ? left : right +} + +/** Shared aspect ratio for both cells in a pair row (matches the shorter image). */ +export function pairCellStyle(left: CarouselImage, right: CarouselImage): { aspectRatio: string } { + return photoCellStyle(shorterImageInPair(left, right)) +} + +export function photoGridRowKey(row: PhotoGridRow): string { + switch (row.kind) { + case 'full': + case 'single': + return row.image.title ?? row.image.url + case 'pair': + return `${row.left.title ?? row.left.url}|${row.right.title ?? row.right.url}` + } +} + +/** Incrementally commits rows from a buffer — already-emitted rows never change. */ +export class PhotoGridLayoutState { + rows: PhotoGridRow[] = [] + private buffer: CarouselImage[] = [] + + reset(): void { + this.rows = [] + this.buffer = [] + } + + appendImages(images: CarouselImage[]): void { + if (!images.length) return + this.buffer.push(...images) + this.processBuffer(false) + } + + flush(): void { + this.processBuffer(true) + } + + private processBuffer(flush: boolean): void { + while (this.buffer.length > 0) { + const head = this.buffer[0] + + if (isFullWidthLandscape(head)) { + this.rows.push({ kind: 'full', image: this.buffer.shift()! }) + continue + } + + const leftRatio = imageAspectRatio(head) + let bestIdx = -1 + let bestScore = Infinity + + for (let i = 1; i < this.buffer.length; i++) { + if (isFullWidthLandscape(this.buffer[i])) continue + const score = Math.abs(leftRatio - imageAspectRatio(this.buffer[i])) + if (score < bestScore) { + bestScore = score + bestIdx = i + } + } + + if (bestIdx >= 0) { + const right = this.buffer.splice(bestIdx, 1)[0] + const left = this.buffer.shift()! + this.rows.push({ kind: 'pair', left, right }) + continue + } + + if (!flush) break + + const item = this.buffer.shift()! + if (isFullWidthLandscape(item)) { + this.rows.push({ kind: 'full', image: item }) + } else { + this.rows.push({ kind: 'single', image: item }) + } + } + } +} diff --git a/src/prototypes/musical-group/scrollTabIntoTrackView.ts b/src/prototypes/musical-group/scrollTabIntoTrackView.ts new file mode 100644 index 0000000..79f2c0c --- /dev/null +++ b/src/prototypes/musical-group/scrollTabIntoTrackView.ts @@ -0,0 +1,14 @@ +/** Scroll a tab button into the visible area of a horizontal tab track, preserving inline padding. */ +export function scrollTabIntoTrackView(button: HTMLElement, track: HTMLElement) { + const buttonRect = button.getBoundingClientRect() + const trackRect = track.getBoundingClientRect() + const trackStyle = getComputedStyle(track) + const paddingLeft = parseFloat(trackStyle.paddingLeft) || 0 + const paddingRight = parseFloat(trackStyle.paddingRight) || 0 + + if (buttonRect.left < trackRect.left) { + track.scrollLeft -= trackRect.left - buttonRect.left + paddingLeft + } else if (buttonRect.right > trackRect.right) { + track.scrollLeft += buttonRect.right - trackRect.right + paddingRight + } +} diff --git a/src/prototypes/musical-group/useActivityFeed.ts b/src/prototypes/musical-group/useActivityFeed.ts new file mode 100644 index 0000000..2d24a34 --- /dev/null +++ b/src/prototypes/musical-group/useActivityFeed.ts @@ -0,0 +1,298 @@ +import { onUnmounted, ref, watch, type Ref } from 'vue' + +import { mapWithConcurrency } from '@/lib/mapWithConcurrency' + +import { + fetchLatestRecentChanges, + fetchLatestRevisionsForTitles, + fetchNextActivityCandidates, + fetchRecentChangeForItem, + initPageActivityStates, + type ActivityCandidate, + type LatestRevision, + type PageActivityState, +} from './data/fetchRecentChanges' +import { getCachedActivityFeed, setCachedActivityFeed } from './data/homeTabCache' +import { savedPagesListKey } from './data/cacheKeys' +import type { HomeRecentChange, HomeSavedItem } from './data/types' + +/** How many classified change cards to resolve per loadMore call. */ +const PAGE_SIZE = 3 + +export type ActivityFeedMode = 'latest' | 'full' + +/** + * Activity feed for saved pages. `latest` returns one classified edit per page; + * `full` paginates revision history as the user scrolls. + */ +export function useActivityFeed( + savedItems: Ref, + active: Ref, + mode: Ref = ref('full'), +) { + const changes = ref([]) + const loading = ref(false) + const hasMore = ref(true) + const error = ref(null) + const queueReady = ref(false) + const itemIdsWithoutRevisions = ref([]) + const revisionLookupFailed = ref(false) + + let queue: ActivityCandidate[] = [] + let pageStates: PageActivityState[] = [] + let latestRevidByTitle = new Map() + const seenRevids = new Set() + let fetchAbort: AbortController | null = null + let loadedForKey: string | null = null + + function savedKey(): string { + return savedPagesListKey(savedItems.value) + } + + function feedCacheKey(listKey: string): string { + return `${mode.value}:${listKey}` + } + + function resetState() { + changes.value = [] + loading.value = false + error.value = null + queueReady.value = false + itemIdsWithoutRevisions.value = [] + revisionLookupFailed.value = false + queue = [] + pageStates = [] + latestRevidByTitle = new Map() + seenRevids.clear() + hasMore.value = true + } + + function persistState() { + const listKey = savedKey() + setCachedActivityFeed({ + dependencyKey: feedCacheKey(listKey), + changes: changes.value, + seenRevids: [...seenRevids], + pageStates: pageStates.map((state) => ({ + itemId: state.item.id, + itemTitle: state.item.title, + enwikiTitle: state.item.enwikiTitle, + thumbnailUrl: state.item.thumbnailUrl, + savedAt: state.item.savedAt, + oldestRevid: state.oldestRevid, + exhausted: state.exhausted, + })), + latestRevidByTitle: [...latestRevidByTitle.entries()], + queue: queue.map(({ item, revision }) => ({ + itemId: item.id, + itemTitle: item.title, + enwikiTitle: item.enwikiTitle, + thumbnailUrl: item.thumbnailUrl, + savedAt: item.savedAt, + revision, + })), + hasMore: hasMore.value, + fetchedAt: Date.now(), + }) + } + + function restoreFromCache(listKey: string): boolean { + const cached = getCachedActivityFeed(feedCacheKey(listKey)) + if (!cached) return false + + changes.value = cached.changes + hasMore.value = cached.hasMore + queueReady.value = true + seenRevids.clear() + for (const revid of cached.seenRevids) seenRevids.add(revid) + + pageStates = cached.pageStates.map((state) => ({ + item: { + id: state.itemId, + title: state.itemTitle, + enwikiTitle: state.enwikiTitle, + description: '', + thumbnailUrl: state.thumbnailUrl, + savedAt: state.savedAt, + }, + oldestRevid: state.oldestRevid, + exhausted: state.exhausted, + })) + + latestRevidByTitle = new Map(cached.latestRevidByTitle) + queue = cached.queue.map((entry) => ({ + item: { + id: entry.itemId, + title: entry.itemTitle, + enwikiTitle: entry.enwikiTitle, + description: '', + thumbnailUrl: entry.thumbnailUrl, + savedAt: entry.savedAt, + }, + revision: entry.revision as LatestRevision, + })) + + loading.value = false + error.value = null + return true + } + + function updateHasMore() { + hasMore.value = queue.length > 0 || pageStates.some((state) => !state.exhausted) + } + + async function refillQueue(signal: AbortSignal) { + const fresh = await fetchNextActivityCandidates(pageStates, seenRevids, signal) + if (fresh.length) { + queue.push(...fresh) + } + updateHasMore() + } + + async function refreshLatestRevids(signal: AbortSignal) { + const titles = savedItems.value + .map((item) => item.enwikiTitle) + .filter((title): title is string => Boolean(title)) + if (!titles.length) { + latestRevidByTitle = new Map() + return + } + + const { revisions } = await fetchLatestRevisionsForTitles(titles, signal) + latestRevidByTitle = new Map( + [...revisions.entries()].map(([key, revision]) => [key, revision.revid]), + ) + } + + async function ensureQueue(signal: AbortSignal) { + if (!pageStates.length) { + pageStates = initPageActivityStates(savedItems.value) + await refreshLatestRevids(signal) + } + + if (!queue.length) { + await refillQueue(signal) + } + + queueReady.value = true + } + + async function loadLatest(signal: AbortSignal) { + const { changes: fresh, itemIdsWithoutRevisions: missing, revisionLookupFailed: lookupFailed } = + await fetchLatestRecentChanges(savedItems.value, signal) + changes.value = fresh + itemIdsWithoutRevisions.value = missing + revisionLookupFailed.value = lookupFailed + hasMore.value = false + queueReady.value = true + persistState() + } + + async function loadMore() { + if (!active.value || loading.value || !hasMore.value) return + + fetchAbort?.abort() + fetchAbort = new AbortController() + const { signal } = fetchAbort + + loading.value = true + error.value = null + + try { + if (mode.value === 'latest') { + await loadLatest(signal) + return + } + + await ensureQueue(signal) + + if (!queue.length) { + hasMore.value = false + persistState() + return + } + + const batch = queue.splice(0, PAGE_SIZE) + if (batch.length) { + const resolved = await mapWithConcurrency( + batch, + PAGE_SIZE, + ({ item, revision }) => + fetchRecentChangeForItem(item, signal, revision, latestRevidByTitle).catch((err) => { + if ((err as Error).name === 'AbortError') throw err + return null + }), + signal, + ) + const fresh = resolved.filter((change): change is HomeRecentChange => change !== null) + if (fresh.length) { + changes.value = [...changes.value, ...fresh] + } + } + + updateHasMore() + persistState() + } catch (err) { + if ((err as Error).name === 'AbortError') return + error.value = 'Could not load activity.' + hasMore.value = false + } finally { + loading.value = false + } + } + + function retry() { + if (!active.value) return + loadedForKey = null + resetState() + void loadMore() + } + + watch( + () => [savedKey(), active.value, mode.value] as const, + ([key, isActive, feedMode], oldValue) => { + const prevKey = oldValue?.[0] + const prevMode = oldValue?.[2] + + if (key !== prevKey || feedMode !== prevMode) { + loadedForKey = null + } + + if (!isActive) { + fetchAbort?.abort() + fetchAbort = null + loading.value = false + return + } + + const loadKey = `${key}:${feedMode}` + if (loadedForKey === loadKey) return + + loadedForKey = loadKey + fetchAbort?.abort() + fetchAbort = null + + if (restoreFromCache(key)) return + + resetState() + void loadMore() + }, + { immediate: true }, + ) + + onUnmounted(() => { + fetchAbort?.abort() + }) + + return { + changes, + loading, + hasMore, + error, + queueReady, + itemIdsWithoutRevisions, + revisionLookupFailed, + loadMore, + retry, + } +} diff --git a/src/prototypes/musical-group/useCommonsPhotosFeed.ts b/src/prototypes/musical-group/useCommonsPhotosFeed.ts new file mode 100644 index 0000000..341e225 --- /dev/null +++ b/src/prototypes/musical-group/useCommonsPhotosFeed.ts @@ -0,0 +1,275 @@ +import { onUnmounted, ref, shallowRef, watch, type Ref } from 'vue' + +import { + carouselImageDedupeKey, + createCommonsPhotosFeedCursor, + fetchCommonsPhotosBatch, + normalizeFileTitle, + resolveCommonsCategory, + type CommonsPhotosFeedCursor, +} from './data/commonsImages' +import { + getCachedMusicalGroup, + setCachedMusicalGroupCommonsPhotos, +} from './data/musicalGroupCache' +import type { CarouselImage, MusicalGroupData } from './data/types' + +function seedSeenKeys(data: MusicalGroupData, carouselImages: CarouselImage[]): Set { + const seen = new Set() + + if (data.imageFilename) { + seen.add(normalizeFileTitle(data.imageFilename)) + } + + for (const image of carouselImages) { + seen.add(carouselImageDedupeKey(image)) + } + + return seen +} + +function feedSourceFromData(data: MusicalGroupData) { + const category = resolveCommonsCategory(data) ?? null + return { + imageFilename: data.imageFilename ?? null, + commonsCategory: category, + label: data.label, + } +} + +function dedupeCarouselImages(images: CarouselImage[]): CarouselImage[] { + const seen = new Set() + const unique: CarouselImage[] = [] + + for (const image of images) { + const key = carouselImageDedupeKey(image) + if (seen.has(key)) continue + seen.add(key) + unique.push(image) + } + + return unique +} + +export function useCommonsPhotosFeed(data: Ref, active: Ref) { + const images = ref([]) + const loading = ref(false) + const hasMore = ref(true) + const error = ref(null) + + const seenKeys = shallowRef(new Set()) + let cursor: CommonsPhotosFeedCursor | null = null + let fetchAbort: AbortController | null = null + let loadedForDataId: string | null = null + + function persistFeedState() { + setCachedMusicalGroupCommonsPhotos(data.value.id, { + images: images.value, + seenKeys: [...seenKeys.value], + hasMore: hasMore.value, + }) + } + + function resetFeed(nextData: MusicalGroupData) { + fetchAbort?.abort() + fetchAbort = null + + const cached = getCachedMusicalGroup(nextData.id)?.commonsPhotos + if (cached?.images.length) { + images.value = dedupeCarouselImages([...cached.images]) + seenKeys.value = new Set(cached.seenKeys) + hasMore.value = cached.hasMore + cursor = createCommonsPhotosFeedCursor(feedSourceFromData(nextData), { categoryOnly: true }) + loading.value = false + error.value = null + loadedForDataId = nextData.id + return + } + + images.value = dedupeCarouselImages([...nextData.images]) + seenKeys.value = seedSeenKeys(nextData, nextData.images) + cursor = createCommonsPhotosFeedCursor(feedSourceFromData(nextData), { categoryOnly: true }) + hasMore.value = true + loading.value = false + error.value = null + loadedForDataId = nextData.id + } + + async function loadMore() { + if (!active.value || loading.value || !hasMore.value || !cursor) return + + fetchAbort?.abort() + fetchAbort = new AbortController() + + loading.value = true + error.value = null + + try { + const source = feedSourceFromData(data.value) + const batch = await fetchCommonsPhotosBatch(source, cursor, { + seenTitles: seenKeys.value, + signal: fetchAbort.signal, + }) + + cursor = batch.cursor + hasMore.value = batch.hasMore + + if (batch.images.length) { + const nextSeen = new Set(seenKeys.value) + const fresh: CarouselImage[] = [] + + for (const image of batch.images) { + const key = carouselImageDedupeKey(image) + if (nextSeen.has(key)) continue + nextSeen.add(key) + fresh.push(image) + } + + seenKeys.value = nextSeen + if (fresh.length) { + images.value = dedupeCarouselImages([...images.value, ...fresh]) + } + } + + persistFeedState() + } catch (err) { + if ((err as Error).name === 'AbortError') return + error.value = 'Could not load more images.' + hasMore.value = false + } finally { + loading.value = false + } + } + + watch( + () => [data.value.id, active.value] as const, + ([dataId, isActive], oldValue) => { + const prevDataId = oldValue?.[0] + + if (dataId !== prevDataId) { + loadedForDataId = null + } + + if (!isActive) { + fetchAbort?.abort() + fetchAbort = null + loading.value = false + return + } + + if (loadedForDataId === dataId && images.value.length > 0) { + return + } + + resetFeed(data.value) + const cached = getCachedMusicalGroup(data.value.id)?.commonsPhotos + if (cached?.images.length) return + void loadMore() + }, + { immediate: true }, + ) + + onUnmounted(() => { + fetchAbort?.abort() + }) + + return { + images, + loading, + hasMore, + error, + loadMore, + } +} + +export function useCommonsPhotosInfiniteScroll(options: { + sentinel: Ref + active: Ref + hasMore: Ref + loading: Ref + loadMore: () => void | Promise +}) { + let observer: IntersectionObserver | null = null + let scrollRoot: HTMLElement | null = null + + function disconnect() { + observer?.disconnect() + observer = null + scrollRoot?.removeEventListener('scroll', onScroll) + scrollRoot = null + } + + function sentinelNearViewport(): boolean { + const target = options.sentinel.value + if (!scrollRoot || !target) return false + + const rootRect = scrollRoot.getBoundingClientRect() + const targetRect = target.getBoundingClientRect() + return targetRect.top <= rootRect.bottom + 120 + } + + function nearScrollEnd(): boolean { + if (!scrollRoot) return false + return ( + scrollRoot.scrollTop + scrollRoot.clientHeight >= scrollRoot.scrollHeight - 160 + ) + } + + function maybeLoadMore() { + if (!options.active.value || options.loading.value || !options.hasMore.value) return + if (!sentinelNearViewport() && !nearScrollEnd()) return + void options.loadMore() + } + + function onScroll() { + maybeLoadMore() + } + + function connect() { + disconnect() + + scrollRoot = document.querySelector('.musical-group-page') as HTMLElement | null + const target = options.sentinel.value + if (!scrollRoot || !target || !options.active.value) return + + scrollRoot.addEventListener('scroll', onScroll, { passive: true }) + + observer = new IntersectionObserver( + (entries) => { + if (!entries.some((entry) => entry.isIntersecting)) return + maybeLoadMore() + }, + { root: scrollRoot, rootMargin: '120px' }, + ) + + observer.observe(target) + + requestAnimationFrame(() => { + maybeLoadMore() + }) + } + + watch( + [options.sentinel, options.active], + () => { + if (options.active.value) { + connect() + } else { + disconnect() + } + }, + { flush: 'post' }, + ) + + watch( + () => options.loading.value, + (isLoading, wasLoading) => { + if (!wasLoading || isLoading) return + requestAnimationFrame(() => { + maybeLoadMore() + }) + }, + ) + + onUnmounted(disconnect) +} diff --git a/src/prototypes/musical-group/useContributeSuggestionsFeed.ts b/src/prototypes/musical-group/useContributeSuggestionsFeed.ts new file mode 100644 index 0000000..2898e6d --- /dev/null +++ b/src/prototypes/musical-group/useContributeSuggestionsFeed.ts @@ -0,0 +1,332 @@ +import { onUnmounted, ref, watch, type Ref } from 'vue' + +import { savedPagesListKey } from './data/cacheKeys' +import { normalizeEnwikiTitle } from './data/enwikiTitle' +import { fetchAllSavedSuggestions, fetchEditSuggestionForPage } from './data/fetchEditSuggestion' +import { fetchMorelikeTitles, resolveRelatedSummary } from './data/fetchRelatedReading' +import { getCachedContributeFeed, setCachedContributeFeed } from './data/homeTabCache' +import type { HomeHelpWanted, HomeSavedItem } from './data/types' + +/** How many related suggestions to resolve per loadMore call. */ +const PAGE_SIZE = 5 +/** Refill the title pool from another seed once it drops below this. */ +const REFILL_THRESHOLD = PAGE_SIZE +/** Titles fetched per morelike API call. */ +const MORELIKE_BATCH = 20 +/** Minimum titles to add from one seed per refill round. */ +const TITLES_PER_SEED = 2 + +function titlesPerSeed(seedCount: number): number { + return Math.max(TITLES_PER_SEED, Math.ceil(REFILL_THRESHOLD / Math.max(seedCount, 1))) +} + +interface SeedCursor { + searchTitle: string + displayTitle: string + offset: number +} + +interface PooledTitle { + title: string + relatedToTitle: string +} + +function titleKey(title: string): string { + return normalizeEnwikiTitle(title).toLowerCase() +} + +function shuffleSeeds(seeds: SeedCursor[]): void { + for (let i = seeds.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)) + ;[seeds[i], seeds[j]] = [seeds[j], seeds[i]] + } +} + +export function useContributeSuggestionsFeed( + savedItems: Ref, + active: Ref, +) { + const savedSuggestions = ref([]) + const savedLoading = ref(false) + const relatedSuggestions = ref([]) + const relatedLoading = ref(false) + const relatedHasMore = ref(true) + const error = ref(null) + + let seenTitles = new Set() + let excludedIds = new Set() + let seedTitles = new Set() + let seeds: SeedCursor[] = [] + let titlePool: PooledTitle[] = [] + let nextSeedIndex = 0 + let savedAbort: AbortController | null = null + let relatedAbort: AbortController | null = null + let loadedForKey: string | null = null + let savedLoadedForKey: string | null = null + + function savedKey(): string { + return savedPagesListKey(savedItems.value) + } + + function persistState() { + setCachedContributeFeed({ + dependencyKey: savedKey(), + savedSuggestions: savedSuggestions.value, + relatedSuggestions: relatedSuggestions.value, + seenTitles: [...seenTitles], + excludedIds: [...excludedIds], + seedTitles: [...seedTitles], + seeds, + titlePool, + nextSeedIndex, + relatedHasMore: relatedHasMore.value, + fetchedAt: Date.now(), + }) + } + + function restoreFromCache(key: string): boolean { + const cached = getCachedContributeFeed(key) + if (!cached) return false + + savedSuggestions.value = cached.savedSuggestions + relatedSuggestions.value = cached.relatedSuggestions + seenTitles = new Set(cached.seenTitles) + excludedIds = new Set(cached.excludedIds) + seedTitles = new Set(cached.seedTitles) + seeds = cached.seeds + titlePool = cached.titlePool + nextSeedIndex = cached.nextSeedIndex + relatedHasMore.value = cached.relatedHasMore + savedLoading.value = false + relatedLoading.value = false + error.value = null + savedLoadedForKey = key + return true + } + + function resetRelatedState() { + relatedAbort?.abort() + relatedAbort = null + + relatedSuggestions.value = [] + relatedLoading.value = false + error.value = null + + seenTitles = new Set() + excludedIds = new Set() + seedTitles = new Set() + seeds = [] + titlePool = [] + nextSeedIndex = 0 + + for (const item of savedItems.value) { + excludedIds.add(item.id) + if (!item.enwikiTitle) continue + const key = titleKey(item.enwikiTitle) + seenTitles.add(key) + if (seedTitles.has(key)) continue + seedTitles.add(key) + seeds.push({ searchTitle: item.enwikiTitle, displayTitle: item.title, offset: 0 }) + } + + shuffleSeeds(seeds) + relatedHasMore.value = seeds.length > 0 + } + + function promoteRelatedSeed(title: string) { + const key = titleKey(title) + if (seedTitles.has(key)) return + seedTitles.add(key) + seeds.push({ searchTitle: title, displayTitle: title, offset: 0 }) + } + + async function refillPool(signal: AbortSignal) { + if (!seeds.length) { + relatedHasMore.value = false + return + } + + let passes = 0 + const maxPasses = Math.max(seeds.length * 2, 4) + + while (titlePool.length < REFILL_THRESHOLD && passes < maxPasses) { + const start = nextSeedIndex + + for (let i = 0; i < seeds.length && titlePool.length < REFILL_THRESHOLD; i++) { + const seed = seeds[(start + i) % seeds.length] + const titles = await fetchMorelikeTitles( + seed.searchTitle, + signal, + MORELIKE_BATCH, + seed.offset, + ) + seed.offset += MORELIKE_BATCH + + const perSeed = titlesPerSeed(seeds.length) + let added = 0 + for (const title of titles) { + if (added >= perSeed) break + + const key = titleKey(title) + if (seenTitles.has(key)) continue + seenTitles.add(key) + titlePool.push({ title, relatedToTitle: seed.displayTitle }) + added++ + } + } + + nextSeedIndex = (start + seeds.length) % seeds.length + passes++ + } + + relatedHasMore.value = seeds.length > 0 + } + + async function resolveRelatedSuggestion( + { title, relatedToTitle }: PooledTitle, + signal: AbortSignal, + ): Promise { + const summary = await resolveRelatedSummary(title, relatedToTitle, signal) + if (!summary) return null + + const trackKey = summary.itemId ?? `enwiki:${titleKey(title)}` + if (excludedIds.has(trackKey)) return null + + excludedIds.add(trackKey) + + const suggestion = await fetchEditSuggestionForPage( + { + itemId: summary.itemId, + title: summary.title, + enwikiTitle: title, + thumbnailUrl: summary.thumbnailUrl, + }, + relatedToTitle, + signal, + 'musical-group-contribute-related', + ) + + if (suggestion) { + promoteRelatedSeed(title) + } + + return suggestion + } + + async function loadSavedSuggestions(key: string) { + savedAbort?.abort() + savedAbort = new AbortController() + const { signal } = savedAbort + + savedSuggestions.value = [] + savedLoading.value = true + + try { + await fetchAllSavedSuggestions(savedItems.value, signal, { + onEach: (suggestion) => { + savedSuggestions.value = [...savedSuggestions.value, suggestion] + }, + }) + savedLoadedForKey = key + persistState() + } catch (err) { + if ((err as Error).name === 'AbortError') return + savedSuggestions.value = [] + } finally { + savedLoading.value = false + } + } + + async function loadMoreRelated() { + if (!active.value || relatedLoading.value || savedLoading.value || !relatedHasMore.value) { + return + } + + relatedAbort?.abort() + relatedAbort = new AbortController() + const { signal } = relatedAbort + + relatedLoading.value = true + error.value = null + + try { + await refillPool(signal) + + let resolvedCount = 0 + let attempts = 0 + const maxAttempts = PAGE_SIZE * 4 + + while (resolvedCount < PAGE_SIZE && titlePool.length && attempts < maxAttempts) { + const pooled = titlePool.shift() + if (!pooled) break + attempts++ + + const suggestion = await resolveRelatedSuggestion(pooled, signal) + if (suggestion) { + relatedSuggestions.value = [...relatedSuggestions.value, suggestion] + resolvedCount++ + } + } + + relatedHasMore.value = seeds.length > 0 + persistState() + } catch (err) { + if ((err as Error).name === 'AbortError') return + error.value = 'Could not load more edit suggestions.' + relatedHasMore.value = false + } finally { + relatedLoading.value = false + } + } + + watch( + () => [savedKey(), active.value] as const, + ([key, isActive], oldValue) => { + const prevKey = oldValue?.[0] + + if (key !== prevKey) { + loadedForKey = null + savedLoadedForKey = null + } + + if (!isActive) { + savedAbort?.abort() + relatedAbort?.abort() + savedAbort = null + relatedAbort = null + savedLoading.value = false + relatedLoading.value = false + return + } + + if (loadedForKey === key) return + + loadedForKey = key + if (restoreFromCache(key)) return + + resetRelatedState() + + void (async () => { + await loadSavedSuggestions(key) + if (!active.value || savedLoadedForKey !== key) return + void loadMoreRelated() + })() + }, + { immediate: true }, + ) + + onUnmounted(() => { + savedAbort?.abort() + relatedAbort?.abort() + }) + + return { + savedSuggestions, + savedLoading, + relatedSuggestions, + relatedLoading, + relatedHasMore, + error, + loadMoreRelated, + } +} diff --git a/src/prototypes/musical-group/useEntityExternalLinks.ts b/src/prototypes/musical-group/useEntityExternalLinks.ts new file mode 100644 index 0000000..c8ba63d --- /dev/null +++ b/src/prototypes/musical-group/useEntityExternalLinks.ts @@ -0,0 +1,61 @@ +import { ref, watch, type Ref } from 'vue' + +import { fetchEntityExternalLinks } from './data/fetchEntityExternalLinks' +import { + getCachedMusicalGroup, + setCachedMusicalGroupExternalLinks, +} from './data/musicalGroupCache' +import type { WikidataExternalLink } from './data/types' + +export function useEntityExternalLinks(qid: Ref) { + const links = ref([]) + const loading = ref(false) + const error = ref(null) + + let fetchAbort: AbortController | null = null + + async function loadLinks(id: string) { + fetchAbort?.abort() + fetchAbort = new AbortController() + + const cached = getCachedMusicalGroup(id) + if (cached?.externalLinks) { + links.value = cached.externalLinks + loading.value = false + error.value = null + return + } + + loading.value = true + error.value = null + links.value = [] + + try { + const loaded = await fetchEntityExternalLinks(id, fetchAbort.signal) + links.value = loaded + setCachedMusicalGroupExternalLinks(id, loaded) + } catch (err) { + if ((err as Error).name === 'AbortError') return + error.value = 'Could not load links. Try again.' + } finally { + loading.value = false + } + } + + watch( + qid, + (id) => { + if (!id) { + fetchAbort?.abort() + links.value = [] + loading.value = false + error.value = null + return + } + void loadLinks(id) + }, + { immediate: true }, + ) + + return { links, loading, error } +} diff --git a/src/prototypes/musical-group/useMusicalGroupHome.ts b/src/prototypes/musical-group/useMusicalGroupHome.ts new file mode 100644 index 0000000..3ea9e83 --- /dev/null +++ b/src/prototypes/musical-group/useMusicalGroupHome.ts @@ -0,0 +1,337 @@ +import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue' +import { useRoute } from 'vue-router' + +import { listBookmarks } from './data/bookmarks' +import { bookmarksKey, utcDayKey } from './data/cacheKeys' +import { fetchFeaturedTabContent, isUsableFeaturedTab } from './data/fetchFeaturedFeed' +import { fetchHelpWanted } from './data/fetchHelpWanted' +import { fetchRecentChanges } from './data/fetchRecentChanges' +import { fetchSavedItemSummaries } from './data/fetchSavedItemSummaries' +import { fetchTrendingFeed, isTrendingSummaryIncomplete } from './data/fetchTrending' +import { clearFeaturedFeedSessionCache } from './data/fetchEnwikiFeaturedFeedDay' +import { clearFeaturedTabSessionCache } from './data/fetchFeaturedFeed' +import { clearTrendingSessionCache } from './data/fetchTrending' +import { + clearCachedFeaturedTab, + clearCachedTrendingFeed, + getCachedFeaturedTab, + getCachedHelpWanted, + getCachedRecentChangesPreview, + getCachedSavedSummaries, + getCachedTrendingFeed, +} from './data/homeTabCache' +import type { + HomeFeaturedTab, + HomeHelpWanted, + HomeRecentChange, + HomeSavedItem, + HomeTrending, +} from './data/types' + +const DAY_MS = 24 * 60 * 60 * 1000 +const MIN_RECENTLY_SAVED = 2 +const MAX_RECENTLY_SAVED = 5 + +const EMPTY_FEATURED_TAB: HomeFeaturedTab = { + didYouKnow: [], + bornOnThisDay: [], +} + +function isAbort(err: unknown): boolean { + return (err as Error)?.name === 'AbortError' +} + +export function useMusicalGroupHome() { + const route = useRoute() + const featuredTab = ref(EMPTY_FEATURED_TAB) + const featuredTabLoading = ref(true) + const featuredTabError = ref(null) + const trendingItems = ref([]) + const trendingLoading = ref(true) + const trendingTabError = ref(null) + const hasSavedPages = ref(listBookmarks().length > 0) + const savedItems = ref([]) + const savedItemsLoading = ref(listBookmarks().length > 0) + const helpWanted = ref([]) + const recentChanges = ref([]) + const helpWantedLoading = ref(listBookmarks().length > 0) + const recentChangesLoading = ref(listBookmarks().length > 0) + + const featuredArticle = computed(() => featuredTab.value.article) + const didYouKnow = computed(() => featuredTab.value.didYouKnow) + const bornOnThisDay = computed(() => featuredTab.value.bornOnThisDay) + + const savedSorted = computed(() => + [...savedItems.value].sort((a, b) => b.savedAt - a.savedAt), + ) + + const recentlySaved = computed(() => { + if (!savedSorted.value.length) return [] + const now = Date.now() + const withinDay = savedSorted.value.filter((item) => now - item.savedAt <= DAY_MS).length + const count = Math.min(MAX_RECENTLY_SAVED, Math.max(MIN_RECENTLY_SAVED, withinDay)) + return savedSorted.value.slice(0, count) + }) + + let abort: AbortController | null = null + let bookmarkAbort: AbortController | null = null + + function hydrateBookmarksFromCache(): void { + const dependencyKey = bookmarksKey() + const cachedSummaries = getCachedSavedSummaries(dependencyKey) + if (cachedSummaries) { + savedItems.value = cachedSummaries + } + + const cachedHelp = getCachedHelpWanted(dependencyKey) + if (cachedHelp) helpWanted.value = cachedHelp + + const cachedRecent = getCachedRecentChangesPreview(dependencyKey) + if (cachedRecent) recentChanges.value = cachedRecent + } + + function loadPersonalizedFeeds(items: HomeSavedItem[], signal: AbortSignal): void { + if (!items.length) { + helpWanted.value = [] + recentChanges.value = [] + helpWantedLoading.value = false + recentChangesLoading.value = false + return + } + + helpWantedLoading.value = true + recentChangesLoading.value = true + + fetchHelpWanted(items, signal) + .then((value) => { + helpWanted.value = value + }) + .catch((err) => { + if (isAbort(err)) return + }) + .finally(() => { + helpWantedLoading.value = false + }) + + fetchRecentChanges(items, signal) + .then((value) => { + recentChanges.value = value + }) + .catch((err) => { + if (isAbort(err)) return + }) + .finally(() => { + recentChangesLoading.value = false + }) + } + + function reloadBookmarks(): void { + bookmarkAbort?.abort() + bookmarkAbort = new AbortController() + const { signal } = bookmarkAbort + + const entries = listBookmarks() + hasSavedPages.value = entries.length > 0 + if (!entries.length) { + savedItems.value = [] + savedItemsLoading.value = false + helpWanted.value = [] + recentChanges.value = [] + helpWantedLoading.value = false + recentChangesLoading.value = false + return + } + + hydrateBookmarksFromCache() + savedItemsLoading.value = true + + if (!getCachedHelpWanted(bookmarksKey())) { + helpWantedLoading.value = true + } + if (!getCachedRecentChangesPreview(bookmarksKey())) { + recentChangesLoading.value = true + } + + fetchSavedItemSummaries(entries, signal) + .then((items) => { + savedItems.value = items + loadPersonalizedFeeds(items, signal) + }) + .catch((err) => { + if (isAbort(err)) return + savedItems.value = [] + helpWanted.value = [] + recentChanges.value = [] + helpWantedLoading.value = false + recentChangesLoading.value = false + }) + .finally(() => { + savedItemsLoading.value = false + }) + } + + async function loadFeatured(signal: AbortSignal): Promise { + const dayKey = utcDayKey() + featuredTabError.value = null + + const cached = getCachedFeaturedTab(dayKey) + if (cached && isUsableFeaturedTab(cached)) { + featuredTab.value = cached + featuredTabLoading.value = false + return + } + + featuredTab.value = EMPTY_FEATURED_TAB + featuredTabLoading.value = true + try { + featuredTab.value = await fetchFeaturedTabContent(signal) + if (!isUsableFeaturedTab(featuredTab.value)) { + featuredTabError.value = 'No featured content is available right now.' + } + } catch (err) { + if (isAbort(err)) return + featuredTab.value = EMPTY_FEATURED_TAB + featuredTabError.value = + err instanceof Error ? err.message : 'Could not load featured content.' + } finally { + featuredTabLoading.value = false + } + } + + async function loadTrending(signal: AbortSignal, options?: { background?: boolean }): Promise { + trendingTabError.value = null + + if (!options?.background && !trendingItems.value.length) { + trendingLoading.value = true + } + + try { + trendingItems.value = await fetchTrendingFeed(signal) + } catch (err) { + if (isAbort(err)) return + if (!trendingItems.value.length) { + trendingItems.value = [] + } + trendingTabError.value = + err instanceof Error ? err.message : 'Could not load trending articles.' + } finally { + trendingLoading.value = false + } + } + + async function refreshIncompleteTrending(signal?: AbortSignal): Promise { + if (!trendingItems.value.some(isTrendingSummaryIncomplete)) return + if (!signal) { + abort?.abort() + abort = new AbortController() + signal = abort.signal + } + try { + trendingItems.value = await fetchTrendingFeed(signal) + } catch (err) { + if (isAbort(err)) return + } + } + + async function retryFeaturedFeed(): Promise { + const dayKey = utcDayKey() + clearFeaturedTabSessionCache() + clearFeaturedFeedSessionCache() + clearCachedFeaturedTab(dayKey) + + abort?.abort() + abort = new AbortController() + await loadFeatured(abort.signal) + } + + async function retryTrendingFeed(): Promise { + const dayKey = utcDayKey() + clearTrendingSessionCache() + clearCachedTrendingFeed(dayKey) + + abort?.abort() + abort = new AbortController() + await loadTrending(abort.signal) + } + + function load(): void { + abort?.abort() + abort = new AbortController() + const { signal } = abort + + const dayKey = utcDayKey() + const featuredCached = getCachedFeaturedTab(dayKey) + const trendingCached = getCachedTrendingFeed(dayKey) + + if (featuredCached && isUsableFeaturedTab(featuredCached)) { + featuredTab.value = featuredCached + featuredTabLoading.value = false + } else { + featuredTab.value = EMPTY_FEATURED_TAB + featuredTabLoading.value = true + } + + if (trendingCached?.length) { + trendingItems.value = trendingCached + trendingLoading.value = false + } else { + trendingItems.value = [] + trendingLoading.value = true + } + + void (async () => { + if (!featuredCached) { + await loadFeatured(signal) + } + if (signal.aborted) return + + await loadTrending(signal, { background: Boolean(trendingCached?.length) }) + if (signal.aborted) return + + reloadBookmarks() + })() + } + + watch( + () => route.query.tab, + (tab) => { + if (tab !== 'trending') return + void refreshIncompleteTrending() + }, + ) + + watch( + () => [route.query.item, route.query.tab] as const, + () => { + reloadBookmarks() + }, + ) + + onMounted(load) + onBeforeUnmount(() => { + abort?.abort() + bookmarkAbort?.abort() + }) + + return { + featuredArticle, + didYouKnow, + bornOnThisDay, + featuredTabLoading, + featuredTabError, + retryFeaturedFeed, + trendingItems, + trendingLoading, + trendingTabError, + retryTrendingFeed, + hasSavedPages, + savedSorted, + recentlySaved, + savedItemsLoading, + helpWanted, + recentChanges, + helpWantedLoading, + recentChangesLoading, + reloadBookmarks, + } +} diff --git a/src/prototypes/musical-group/useMusicalGroupHomeTabScroll.ts b/src/prototypes/musical-group/useMusicalGroupHomeTabScroll.ts new file mode 100644 index 0000000..2086e67 --- /dev/null +++ b/src/prototypes/musical-group/useMusicalGroupHomeTabScroll.ts @@ -0,0 +1,139 @@ +import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue' +import { useRoute } from 'vue-router' + +import type { HomeTabId } from './components/WikitaHomeTabs.vue' +import { + getMusicalGroupScrollPage, + measureMusicalGroupHomeTabContentTopScroll, +} from './musicalGroupScrollOffset' +import { parseHomeTabQuery, useMusicalGroupRoute } from './useMusicalGroupRoute' + +interface PendingSwitch { + from: HomeTabId + to: HomeTabId + scrollTop: number +} + +/** Per-tab scroll memory for the home view; cleared when MusicalGroupHome unmounts. */ +export function useMusicalGroupHomeTabScroll() { + const route = useRoute() + const { activeHomeTab } = useMusicalGroupRoute() + + const tabScrollTops = new Map() + const pendingSwitch = ref(null) + + let scrollRoot: HTMLElement | null = null + let isRestoringScroll = false + let panelResizeObserver: ResizeObserver | null = null + let panelStableTimer: ReturnType | null = null + let pendingScrollTarget: number | null = null + + let previousTab = parseHomeTabQuery(route.query.tab) + + function disconnectPanelObserver() { + panelResizeObserver?.disconnect() + panelResizeObserver = null + pendingScrollTarget = null + if (panelStableTimer) { + clearTimeout(panelStableTimer) + panelStableTimer = null + } + } + + function scrollPageTo(top: number) { + const page = scrollRoot ?? getMusicalGroupScrollPage() + if (!page) return + page.scrollTo({ top: Math.max(0, top), behavior: 'instant' }) + } + + function observePanelForScrollRestore(page: HTMLElement, target: number) { + disconnectPanelObserver() + pendingScrollTarget = target + + const panel = page.querySelector('.musical-group-home__body') + if (!panel) return + + panelResizeObserver = new ResizeObserver(() => { + if (pendingScrollTarget == null) return + scrollPageTo(pendingScrollTarget) + if (panelStableTimer) clearTimeout(panelStableTimer) + panelStableTimer = setTimeout(() => { + disconnectPanelObserver() + }, 500) + }) + panelResizeObserver.observe(panel) + } + + function applyScrollRestore(switchInfo: PendingSwitch) { + const page = scrollRoot ?? getMusicalGroupScrollPage() + if (!page) return + + disconnectPanelObserver() + + tabScrollTops.set(switchInfo.from, switchInfo.scrollTop) + + const target = + tabScrollTops.get(switchInfo.to) ?? measureMusicalGroupHomeTabContentTopScroll(page) + + isRestoringScroll = true + scrollPageTo(target) + observePanelForScrollRestore(page, target) + requestAnimationFrame(() => { + isRestoringScroll = false + }) + } + + function onScroll() { + const page = scrollRoot + if (!page) return + if (isRestoringScroll || pendingSwitch.value) return + + const tab = parseHomeTabQuery(route.query.tab) + tabScrollTops.set(tab, page.scrollTop) + } + + watch( + () => route.query.tab, + (tabRaw) => { + const to = parseHomeTabQuery(tabRaw) + const from = previousTab + + if (from !== to) { + const page = getMusicalGroupScrollPage() + if (page) { + pendingSwitch.value = { + from, + to, + scrollTop: page.scrollTop, + } + } + } + + previousTab = to + }, + { flush: 'sync' }, + ) + + watch(activeHomeTab, async () => { + const switchInfo = pendingSwitch.value + if (!switchInfo) return + pendingSwitch.value = null + + if (route.hash) return + + await nextTick() + requestAnimationFrame(() => { + applyScrollRestore(switchInfo) + }) + }) + + onMounted(() => { + scrollRoot = getMusicalGroupScrollPage() + scrollRoot?.addEventListener('scroll', onScroll, { passive: true }) + }) + + onUnmounted(() => { + scrollRoot?.removeEventListener('scroll', onScroll) + disconnectPanelObserver() + }) +} diff --git a/src/prototypes/musical-group/useMusicalGroupRoute.ts b/src/prototypes/musical-group/useMusicalGroupRoute.ts new file mode 100644 index 0000000..7fd3b5b --- /dev/null +++ b/src/prototypes/musical-group/useMusicalGroupRoute.ts @@ -0,0 +1,117 @@ +import { computed } from 'vue' +import { useRoute, useRouter, type RouteLocationRaw } from 'vue-router' + +import type { HomeTabId } from './components/WikitaHomeTabs.vue' +import type { TabId } from './data/types' +import { normalizeQid } from './data/wikidataApi' + +const TAB_IDS: TabId[] = [ + 'overview', + 'info', + 'article', + 'images', + 'links', + 'activity', + 'contribute', +] + +const HOME_TAB_IDS: HomeTabId[] = [ + 'home', + 'read', + 'featured', + 'trending', + 'activity', + 'contribute', + 'saved', +] + +export function parseTabQuery(raw: unknown): TabId { + if (raw === 'photos') return 'images' + if (typeof raw === 'string' && TAB_IDS.includes(raw as TabId)) { + return raw as TabId + } + return 'overview' +} + +export function parseHomeTabQuery(raw: unknown): HomeTabId { + if (typeof raw === 'string' && HOME_TAB_IDS.includes(raw as HomeTabId)) { + return raw as HomeTabId + } + return 'home' +} + +export function useMusicalGroupRoute() { + const route = useRoute() + const router = useRouter() + + const activeTab = computed(() => parseTabQuery(route.query.tab)) + const activeHomeTab = computed(() => parseHomeTabQuery(route.query.tab)) + + function tabRoute(tab: TabId): RouteLocationRaw { + const query = { ...route.query } + if (tab === 'overview') { + delete query.tab + } else { + query.tab = tab + } + return { query } + } + + function itemRoute(id: string): RouteLocationRaw { + const query = { ...route.query, item: id } + delete query.tab + return { query } + } + + function homeTabRoute(tab: HomeTabId, hash?: string): RouteLocationRaw { + const query = { ...route.query } + if (tab === 'home') { + delete query.tab + } else { + query.tab = tab + } + if (hash) { + return { query, hash: hash.startsWith('#') ? hash : `#${hash}` } + } + return { query, hash: '' } + } + + async function setTab(tab: TabId) { + await router.replace(tabRoute(tab)) + } + + async function setHomeTab(tab: HomeTabId, hash?: string) { + if (tab === activeHomeTab.value && !hash) return + await router.push(homeTabRoute(tab, hash)) + } + + async function goToHomeTab() { + const query = { ...route.query } + delete query.item + delete query.tab + await router.replace({ query }) + } + + async function goToContribute() { + const id = normalizeQid(route.query.item) + if (id) { + await setTab('contribute') + } else { + await setHomeTab('contribute') + } + } + + return { + route, + router, + activeTab, + activeHomeTab, + tabRoute, + homeTabRoute, + itemRoute, + setTab, + setHomeTab, + goToHomeTab, + goToContribute, + } +} diff --git a/src/prototypes/musical-group/useMusicalGroupScrollStates.ts b/src/prototypes/musical-group/useMusicalGroupScrollStates.ts new file mode 100644 index 0000000..2e731a9 --- /dev/null +++ b/src/prototypes/musical-group/useMusicalGroupScrollStates.ts @@ -0,0 +1,185 @@ +import { onMounted, onUnmounted } from 'vue' + +import { + measureMusicalGroupStickyScrollOffset, + measureMusicalGroupTabContentTopScroll, +} from './musicalGroupScrollOffset' + +function syncStickyLayout(page: Element) { + const stack = page.querySelector('.musical-group-chrome-stack') + if (!stack) return + + const gap = parseFloat(getComputedStyle(page).getPropertyValue('--spacing-50')) || 8 + const stackHeight = stack.getBoundingClientRect().height + const tabsTop = stackHeight + gap + + page.style.setProperty('--musical-group-chrome-stack-height', `${stackHeight}px`) + page.style.setProperty('--musical-group-tabs-sticky-top', `${tabsTop}px`) + + const tabsSticky = page.querySelector('.musical-group-tabs-sticky') + if (tabsSticky) { + page.style.setProperty( + '--musical-group-tabs-height', + `${tabsSticky.getBoundingClientRect().height}px`, + ) + } + + page.style.setProperty( + '--musical-group-scroll-margin-top', + `${measureMusicalGroupStickyScrollOffset(page)}px`, + ) +} + +/** Expanded vs collapsed chrome stack height — zero when the title is always one line. */ +function measureTitleCollapseDelta(page: Element, stack: Element): number { + const hadScrolled = page.hasAttribute('data-scrolled') + + page.removeAttribute('data-scrolled') + const expandedHeight = stack.getBoundingClientRect().height + + page.setAttribute('data-scrolled', '') + const collapsedHeight = stack.getBoundingClientRect().height + + if (!hadScrolled) page.removeAttribute('data-scrolled') + + return Math.max(0, expandedHeight - collapsedHeight) +} + +/** Tracks title rule expand (chrome over carousel) and tabs stuck for border / layout state. */ +export function useMusicalGroupScrollStates() { + let scrollRoot: Element | null = null + let resizeObserver: ResizeObserver | null = null + let raf = 0 + let lastScrollTop = 0 + let expandedTabsTopPx = 132 + let titleCollapseDelta = 0 + let remeasureCollapseDelta = true + let titleCollapsed = false + let tabContentScrollThreshold = 0 + + function update() { + if (!scrollRoot) return + + const stack = scrollRoot.querySelector('.musical-group-chrome-stack') + const carouselTrack = scrollRoot.querySelector( + '.musical-group-screen__intro .image-carousel__track', + ) + const tabsSticky = scrollRoot.querySelector('.musical-group-tabs-sticky') + + if (!stack || !tabsSticky) return + + const gap = parseFloat(getComputedStyle(scrollRoot).getPropertyValue('--spacing-50')) || 8 + const pageTop = scrollRoot.getBoundingClientRect().top + const scrollEl = scrollRoot as HTMLElement + const scrollTop = scrollEl.scrollTop + const hasScrolled = scrollTop > 1 + + let wantTitleCollapsed = titleCollapsed + if (scrollTop <= 1) { + wantTitleCollapsed = false + } else if (scrollTop > lastScrollTop) { + wantTitleCollapsed = true + } else if (scrollTop < lastScrollTop) { + wantTitleCollapsed = false + } + + const tabsRect = tabsSticky.getBoundingClientRect() + const tabsAtStickyPosition = tabsRect.top <= pageTop + expandedTabsTopPx + 1 + const tabsStuck = tabsAtStickyPosition && hasScrolled + + scrollRoot.toggleAttribute('data-scrolled', wantTitleCollapsed) + + // Threshold is layout-stable only while tabs are in document flow; once sticky, + // getBoundingClientRect-based measurement tracks scrollTop and never exceeds it. + if (!tabsStuck) { + tabContentScrollThreshold = measureMusicalGroupTabContentTopScroll(scrollEl) + } + + scrollRoot.toggleAttribute( + 'data-page-scrolled', + tabsStuck && scrollTop > tabContentScrollThreshold + 1, + ) + scrollRoot.style.setProperty( + '--musical-group-title-collapse-padding', + wantTitleCollapsed && titleCollapseDelta > 0 ? `${titleCollapseDelta}px` : '0px', + ) + + if (!wantTitleCollapsed) { + if (remeasureCollapseDelta) { + titleCollapseDelta = measureTitleCollapseDelta(scrollRoot, stack) + remeasureCollapseDelta = false + } + expandedTabsTopPx = stack.getBoundingClientRect().height + gap + } + + titleCollapsed = wantTitleCollapsed + const stackRect = stack.getBoundingClientRect() + + let titleExpanded = false + if (carouselTrack) { + const carouselTrackRect = carouselTrack.getBoundingClientRect() + // Full-width title rule only while the sticky stack is over the carousel track — + // not while the short description is scrolling beneath the title. + titleExpanded = + carouselTrackRect.top < stackRect.bottom && + carouselTrackRect.bottom > stackRect.bottom + } + + scrollRoot.toggleAttribute('data-title-expanded', titleExpanded) + scrollRoot.toggleAttribute('data-tabs-stuck', tabsStuck) + + syncStickyLayout(scrollRoot) + lastScrollTop = scrollTop + } + + function scheduleUpdate() { + cancelAnimationFrame(raf) + raf = requestAnimationFrame(update) + } + + function onResize() { + remeasureCollapseDelta = true + scheduleUpdate() + } + + onMounted(() => { + scrollRoot = document.querySelector('.musical-group-page') + if (!scrollRoot) return + + scrollRoot.style.setProperty('--musical-group-title-collapse-padding', '0px') + scrollRoot.addEventListener('scroll', scheduleUpdate, { passive: true }) + window.addEventListener('resize', onResize, { passive: true }) + + resizeObserver = new ResizeObserver(() => { + remeasureCollapseDelta = true + scheduleUpdate() + }) + resizeObserver.observe(scrollRoot) + + const stack = scrollRoot.querySelector('.musical-group-chrome-stack') + if (stack) { + resizeObserver.observe(stack) + const title = stack.querySelector('.wikita-title') + if (title) resizeObserver.observe(title) + const description = scrollRoot.querySelector('.musical-group-screen__description') + if (description) resizeObserver.observe(description) + const tabsSticky = scrollRoot.querySelector('.musical-group-tabs-sticky') + if (tabsSticky) resizeObserver.observe(tabsSticky) + } + + lastScrollTop = scrollRoot.scrollTop + scheduleUpdate() + }) + + onUnmounted(() => { + cancelAnimationFrame(raf) + scrollRoot?.removeEventListener('scroll', scheduleUpdate) + window.removeEventListener('resize', onResize) + resizeObserver?.disconnect() + scrollRoot?.removeAttribute('data-scrolled') + scrollRoot?.removeAttribute('data-title-expanded') + scrollRoot?.removeAttribute('data-tabs-stuck') + scrollRoot?.removeAttribute('data-page-scrolled') + scrollRoot?.style.removeProperty('--musical-group-title-collapse-padding') + }) +} diff --git a/src/prototypes/musical-group/useMusicalGroupTabScroll.ts b/src/prototypes/musical-group/useMusicalGroupTabScroll.ts new file mode 100644 index 0000000..73c03e9 --- /dev/null +++ b/src/prototypes/musical-group/useMusicalGroupTabScroll.ts @@ -0,0 +1,200 @@ +import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue' +import { useRoute } from 'vue-router' + +import type { TabId } from './data/types' +import { + getMusicalGroupScrollPage, + isMusicalGroupTabsStuck, + measureMusicalGroupTabContentTopScroll, +} from './musicalGroupScrollOffset' +import { parseTabQuery, useMusicalGroupRoute } from './useMusicalGroupRoute' + +interface PendingSwitch { + from: TabId + to: TabId + scrollTop: number + tabsStuck: boolean +} + +/** Per-tab scroll memory while tabs are stuck; preserves scroll when tabs are in document flow. */ +export function useMusicalGroupTabScroll() { + const route = useRoute() + const { activeTab } = useMusicalGroupRoute() + + const tabScrollTops = new Map() + const visitedTabs = new Set() + const pendingSwitch = ref(null) + + let scrollRoot: HTMLElement | null = null + let isRestoringScroll = false + let panelResizeObserver: ResizeObserver | null = null + let panelStableTimer: ReturnType | null = null + let pendingScrollTarget: number | null = null + + let previousItem = route.query.item + let previousTab = parseTabQuery(route.query.tab) + let forceScrollToContent = false + + function disconnectPanelObserver() { + panelResizeObserver?.disconnect() + panelResizeObserver = null + pendingScrollTarget = null + if (panelStableTimer) { + clearTimeout(panelStableTimer) + panelStableTimer = null + } + } + + function scrollPageTo(top: number) { + const page = scrollRoot ?? getMusicalGroupScrollPage() + if (!page) return + page.scrollTo({ top: Math.max(0, top), behavior: 'instant' }) + } + + function observePanelForScrollRestore(page: HTMLElement, target: number) { + disconnectPanelObserver() + pendingScrollTarget = target + + const panel = page.querySelector('.musical-group-screen__panel') + if (!panel) return + + panelResizeObserver = new ResizeObserver(() => { + if (pendingScrollTarget == null) return + scrollPageTo(pendingScrollTarget) + if (panelStableTimer) clearTimeout(panelStableTimer) + panelStableTimer = setTimeout(() => { + disconnectPanelObserver() + }, 500) + }) + panelResizeObserver.observe(panel) + } + + function scrollToTabContent() { + const page = scrollRoot ?? getMusicalGroupScrollPage() + if (!page) return + + disconnectPanelObserver() + + const target = measureMusicalGroupTabContentTopScroll(page) + isRestoringScroll = true + scrollPageTo(target) + observePanelForScrollRestore(page, target) + requestAnimationFrame(() => { + isRestoringScroll = false + }) + } + + function requestScrollToTabContent() { + forceScrollToContent = true + } + + function applyScrollRestore(switchInfo: PendingSwitch) { + const page = scrollRoot ?? getMusicalGroupScrollPage() + if (!page) return + + disconnectPanelObserver() + + if (forceScrollToContent) { + forceScrollToContent = false + visitedTabs.add(switchInfo.to) + tabScrollTops.set(switchInfo.to, measureMusicalGroupTabContentTopScroll(page)) + scrollToTabContent() + return + } + + if (switchInfo.tabsStuck) { + visitedTabs.add(switchInfo.from) + tabScrollTops.set(switchInfo.from, switchInfo.scrollTop) + + const isFirstVisit = !visitedTabs.has(switchInfo.to) + const target = isFirstVisit + ? measureMusicalGroupTabContentTopScroll(page) + : (tabScrollTops.get(switchInfo.to) ?? measureMusicalGroupTabContentTopScroll(page)) + + visitedTabs.add(switchInfo.to) + isRestoringScroll = true + scrollPageTo(target) + observePanelForScrollRestore(page, target) + requestAnimationFrame(() => { + isRestoringScroll = false + }) + return + } + + isRestoringScroll = true + scrollPageTo(switchInfo.scrollTop) + requestAnimationFrame(() => { + isRestoringScroll = false + }) + } + + function onScroll() { + const page = scrollRoot + if (!page || !isMusicalGroupTabsStuck(page)) return + if (isRestoringScroll || pendingSwitch.value) return + + const tab = parseTabQuery(route.query.tab) + visitedTabs.add(tab) + tabScrollTops.set(tab, page.scrollTop) + } + + watch( + () => [route.query.item, route.query.tab] as const, + ([item, tabRaw]) => { + const to = parseTabQuery(tabRaw) + + if (item !== previousItem) { + tabScrollTops.clear() + visitedTabs.clear() + pendingSwitch.value = null + disconnectPanelObserver() + previousItem = item + previousTab = to + return + } + + const from = previousTab + if (from !== to) { + const page = getMusicalGroupScrollPage() + if (page) { + pendingSwitch.value = { + from, + to, + scrollTop: page.scrollTop, + tabsStuck: isMusicalGroupTabsStuck(page), + } + } + } + + previousItem = item + previousTab = to + }, + { flush: 'sync' }, + ) + + watch(activeTab, async () => { + const switchInfo = pendingSwitch.value + if (!switchInfo) return + pendingSwitch.value = null + + await nextTick() + requestAnimationFrame(() => { + applyScrollRestore(switchInfo) + }) + }) + + onMounted(() => { + scrollRoot = getMusicalGroupScrollPage() + scrollRoot?.addEventListener('scroll', onScroll, { passive: true }) + }) + + onUnmounted(() => { + scrollRoot?.removeEventListener('scroll', onScroll) + disconnectPanelObserver() + }) + + return { + requestScrollToTabContent, + scrollToTabContent, + } +} diff --git a/src/prototypes/musical-group/usePhotosGridLayout.ts b/src/prototypes/musical-group/usePhotosGridLayout.ts new file mode 100644 index 0000000..701a2a9 --- /dev/null +++ b/src/prototypes/musical-group/usePhotosGridLayout.ts @@ -0,0 +1,43 @@ +import { ref, watch, type Ref } from 'vue' + +import { PhotoGridLayoutState, type PhotoGridRow } from './photosGridLayout' +import type { CarouselImage } from './data/types' + +function imageListIdentity(images: CarouselImage[]): string { + const first = images[0] + return `${images.length}:${first?.title ?? first?.url ?? ''}` +} + +export function usePhotosGridLayout(images: Ref, hasMore: Ref) { + const rows = ref([]) + const state = new PhotoGridLayoutState() + let committedLength = 0 + let lastIdentity = '' + + function syncFromImages() { + const identity = imageListIdentity(images.value) + + if (identity !== lastIdentity && images.value.length <= committedLength) { + state.reset() + committedLength = 0 + } + + lastIdentity = identity + + const pending = images.value.slice(committedLength) + if (pending.length) { + state.appendImages(pending) + committedLength = images.value.length + } + + if (!hasMore.value) { + state.flush() + } + + rows.value = [...state.rows] + } + + watch([images, hasMore], syncFromImages, { immediate: true }) + + return { rows } +} diff --git a/src/prototypes/musical-group/useRelatedReadingFeed.ts b/src/prototypes/musical-group/useRelatedReadingFeed.ts new file mode 100644 index 0000000..3f99a88 --- /dev/null +++ b/src/prototypes/musical-group/useRelatedReadingFeed.ts @@ -0,0 +1,289 @@ +import { onUnmounted, ref, watch, type Ref } from 'vue' + +import { mapWithConcurrency } from '@/lib/mapWithConcurrency' + +import { bookmarksKey } from './data/cacheKeys' +import { normalizeEnwikiTitle } from './data/enwikiTitle' +import { fetchMorelikeTitles, resolveRelatedSummary } from './data/fetchRelatedReading' +import { + getCachedRelatedFeed, + setCachedRelatedFeed, + type RelatedFeedTabId, +} from './data/homeTabCache' +import type { HomeRelated, HomeSavedItem } from './data/types' + +/** How many related cards to resolve per loadMore call. */ +const PAGE_SIZE = 5 +/** Refill the title pool from another seed once it drops below this. */ +const REFILL_THRESHOLD = PAGE_SIZE +/** Titles fetched per morelike API call. */ +const MORELIKE_BATCH = 20 +/** Max titles to add from one saved page per refill round. */ +const TITLES_PER_SEED = 2 +const SUMMARY_CONCURRENCY = 2 + +interface SeedCursor { + searchTitle: string + displayTitle: string + offset: number +} + +interface PooledTitle { + title: string + relatedToTitle: string +} + +function titleKey(title: string): string { + return normalizeEnwikiTitle(title).toLowerCase() +} + +function shuffleSeeds(seeds: SeedCursor[]): void { + for (let i = seeds.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)) + ;[seeds[i], seeds[j]] = [seeds[j], seeds[i]] + } +} + +function persistFeedState( + feedTabId: RelatedFeedTabId, + dependencyKey: string, + related: HomeRelated[], + seen: Set, + seedTitles: Set, + seeds: SeedCursor[], + titlePool: PooledTitle[], + nextSeedIndex: number, + hasMore: boolean, +): void { + setCachedRelatedFeed(feedTabId, { + dependencyKey, + items: related, + seen: [...seen], + seedTitles: [...seedTitles], + seeds, + titlePool, + nextSeedIndex, + hasMore, + fetchedAt: Date.now(), + }) +} + +/** + * A paginated "Related reading" feed: each page draws morelike results from + * saved pages (and later from related cards), resolves them to cards, and + * dedupes against the saved set and everything already shown. Seeds keep + * paginating via sroffset; related items become new seeds as the feed grows. + */ +export function useRelatedReadingFeed( + savedItems: Ref, + active: Ref, + feedTabId: RelatedFeedTabId = 'read', +) { + const related = ref([]) + const loading = ref(false) + const hasMore = ref(true) + const error = ref(null) + + let seen = new Set() + let seedTitles = new Set() + let seeds: SeedCursor[] = [] + let titlePool: PooledTitle[] = [] + let nextSeedIndex = 0 + let fetchAbort: AbortController | null = null + let loadedForKey: string | null = null + + function dependencyKey(): string { + return bookmarksKey() + } + + function savedKey(): string { + return [...savedItems.value] + .map((item) => item.id) + .sort() + .join(',') + } + + function reset() { + fetchAbort?.abort() + fetchAbort = null + + related.value = [] + loading.value = false + error.value = null + + seen = new Set() + seedTitles = new Set() + seeds = [] + titlePool = [] + nextSeedIndex = 0 + + for (const item of savedItems.value) { + if (!item.enwikiTitle) continue + const key = titleKey(item.enwikiTitle) + seen.add(key) + if (seedTitles.has(key)) continue + seedTitles.add(key) + seeds.push({ searchTitle: item.enwikiTitle, displayTitle: item.title, offset: 0 }) + } + + shuffleSeeds(seeds) + hasMore.value = seeds.length > 0 + } + + function restoreFromCache(key: string): boolean { + const cached = getCachedRelatedFeed(feedTabId, key) + if (!cached) return false + + related.value = cached.items + seen = new Set(cached.seen) + seedTitles = new Set(cached.seedTitles) + seeds = cached.seeds + titlePool = cached.titlePool + nextSeedIndex = cached.nextSeedIndex + hasMore.value = cached.hasMore + loading.value = false + error.value = null + return true + } + + function promoteRelatedSeed(item: HomeRelated) { + const key = titleKey(item.title) + if (seedTitles.has(key)) return + seedTitles.add(key) + seeds.push({ searchTitle: item.title, displayTitle: item.title, offset: 0 }) + } + + async function refillPool(signal: AbortSignal) { + if (!seeds.length) { + hasMore.value = false + return + } + + let passes = 0 + const maxPasses = Math.max(seeds.length * 2, 4) + + while (titlePool.length < REFILL_THRESHOLD && passes < maxPasses) { + const start = nextSeedIndex + + for (let i = 0; i < seeds.length && titlePool.length < REFILL_THRESHOLD; i++) { + const seed = seeds[(start + i) % seeds.length] + const titles = await fetchMorelikeTitles( + seed.searchTitle, + signal, + MORELIKE_BATCH, + seed.offset, + ) + seed.offset += MORELIKE_BATCH + + let added = 0 + for (const title of titles) { + if (added >= TITLES_PER_SEED) break + + const key = titleKey(title) + if (seen.has(key)) continue + seen.add(key) + titlePool.push({ title, relatedToTitle: seed.displayTitle }) + added++ + } + } + + nextSeedIndex = (start + seeds.length) % seeds.length + passes++ + } + + hasMore.value = seeds.length > 0 + } + + async function loadMore() { + if (!active.value || loading.value || !hasMore.value) return + + fetchAbort?.abort() + fetchAbort = new AbortController() + const { signal } = fetchAbort + + loading.value = true + error.value = null + + const key = dependencyKey() + + try { + await refillPool(signal) + + const batchTitles = titlePool.splice(0, PAGE_SIZE) + if (batchTitles.length) { + const resolved = await mapWithConcurrency( + batchTitles, + SUMMARY_CONCURRENCY, + ({ title, relatedToTitle }) => resolveRelatedSummary(title, relatedToTitle, signal), + signal, + ) + const fresh = resolved.filter((item): item is HomeRelated => item !== null) + if (fresh.length) { + for (const item of fresh) { + promoteRelatedSeed(item) + } + related.value = [...related.value, ...fresh] + } + } + + hasMore.value = seeds.length > 0 + persistFeedState( + feedTabId, + key, + related.value, + seen, + seedTitles, + seeds, + titlePool, + nextSeedIndex, + hasMore.value, + ) + } catch (err) { + if ((err as Error).name === 'AbortError') return + error.value = 'Could not load more related reading.' + hasMore.value = false + } finally { + loading.value = false + } + } + + watch( + () => [savedKey(), active.value] as const, + ([key, isActive], oldValue) => { + const prevKey = oldValue?.[0] + + if (key !== prevKey) { + loadedForKey = null + } + + if (!isActive) { + fetchAbort?.abort() + fetchAbort = null + loading.value = false + return + } + + if (loadedForKey === key) return + + loadedForKey = key + const depKey = dependencyKey() + if (restoreFromCache(depKey)) return + + reset() + void loadMore() + }, + { immediate: true }, + ) + + onUnmounted(() => { + fetchAbort?.abort() + }) + + return { + related, + loading, + hasMore, + error, + loadMore, + } +} diff --git a/src/prototypes/musical-group/useWikitaArticleLinks.ts b/src/prototypes/musical-group/useWikitaArticleLinks.ts new file mode 100644 index 0000000..e24ac47 --- /dev/null +++ b/src/prototypes/musical-group/useWikitaArticleLinks.ts @@ -0,0 +1,137 @@ +import { ref, type Ref } from 'vue' +import { useRouter } from 'vue-router' + +import { + enwikiArticleUrl, + enwikiTitlesMatch, + fetchWikibaseItemId, + fetchWikibaseItemIds, + isExternalHref, + normalizeEnwikiTitle, + parseEnwikiArticleLink, + resolveExternalUrl, +} from './data/enwikiTitle' +import type { WikitaArticleBlock } from './data/parseWikitaArticle' +import { collectArticleLinkTitles } from './data/parseWikitaArticle' +import { scrollMusicalGroupPageToElement } from './musicalGroupScrollOffset' +import { useMusicalGroupRoute } from './useMusicalGroupRoute' + +const qidCache = new Map() +const MAX_PREFETCH_TITLES = 20 + +export function useWikitaArticleLinks( + articleRoot: Ref, + currentTitle: () => string | undefined, +) { + const router = useRouter() + const { itemRoute } = useMusicalGroupRoute() + const resolving = ref(false) + + function scrollToFragment(fragment: string): boolean { + const root = articleRoot.value + if (!root || !fragment) return false + + const id = decodeURIComponent(fragment) + const escaped = typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(id) : id + const target = + root.querySelector(`#${escaped}`) ?? + root.querySelector(`[id="${id.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"]`) + + if (!target) return false + + const scrollRoot = root.closest('.musical-group-page') as HTMLElement | null + if (scrollRoot) { + scrollMusicalGroupPageToElement(scrollRoot, target) + return true + } + + target.scrollIntoView({ behavior: 'instant', block: 'start' }) + return true + } + + function isSamePageLink(linkTitle: string | null, fragment: string | null): boolean { + if (!fragment) return false + const articleTitle = currentTitle() + if (!linkTitle) return true + if (!articleTitle) return false + return enwikiTitlesMatch(linkTitle, articleTitle) + } + + function rememberQid(title: string, qid: string | undefined): void { + qidCache.set(normalizeEnwikiTitle(title).toLowerCase(), qid) + } + + function cachedQid(title: string): string | undefined | null { + const key = normalizeEnwikiTitle(title).toLowerCase() + if (!qidCache.has(key)) return null + return qidCache.get(key) + } + + async function prefetchLinkTargets(blocks: WikitaArticleBlock[], signal?: AbortSignal): Promise { + const titles = collectArticleLinkTitles(blocks).slice(0, MAX_PREFETCH_TITLES) + if (!titles.length) return + + const unresolved = titles.filter((title) => cachedQid(title) === null) + if (!unresolved.length) return + + const batch = await fetchWikibaseItemIds(unresolved, signal) + for (const [title, qid] of batch) { + rememberQid(title, qid) + } + } + + async function resolveQid(title: string, signal?: AbortSignal): Promise { + const cached = cachedQid(title) + if (cached !== null) return cached ?? undefined + + const qid = await fetchWikibaseItemId(title, signal) + rememberQid(title, qid) + return qid + } + + async function onArticleClick(event: MouseEvent): Promise { + const anchor = (event.target as Element | null)?.closest('a') + if (!anchor) return + + const href = anchor.getAttribute('href') ?? '' + if (!href) return + + const { title, fragment } = parseEnwikiArticleLink(href) + + if (isSamePageLink(title, fragment)) { + event.preventDefault() + scrollToFragment(fragment!) + return + } + + if (isExternalHref(href)) { + event.preventDefault() + window.open(resolveExternalUrl(href), '_blank', 'noopener,noreferrer') + return + } + + if (!title) return + + event.preventDefault() + + if (resolving.value) return + resolving.value = true + + try { + const qid = await resolveQid(title) + if (qid) { + await router.replace(itemRoute(qid)) + return + } + + window.open(enwikiArticleUrl(title), '_blank', 'noopener,noreferrer') + } finally { + resolving.value = false + } + } + + return { + onArticleClick, + prefetchLinkTargets, + } +} diff --git a/src/prototypes/template-homepage/impact/data/fetchUserImpact.ts b/src/prototypes/template-homepage/impact/data/fetchUserImpact.ts index 5b5311c..5d62da5 100644 --- a/src/prototypes/template-homepage/impact/data/fetchUserImpact.ts +++ b/src/prototypes/template-homepage/impact/data/fetchUserImpact.ts @@ -207,7 +207,7 @@ function buildViewProgressPatch( function formatViewCount(total: number): string { if (total >= 1_000_000) return `${(total / 1_000_000).toFixed(1)}M` - if (total >= 1000) return `${(total / 1000).toFixed(1)}K` + if (total >= 1000) return `${(total / 1000).toFixed(1)}k` return total.toLocaleString() }