import React from "react"; import { createSearchParams, Link, useSearchParams, useNavigate, } from "react-router-dom"; import useSWR from "swr"; import dayjs from "dayjs"; import relativeTime from "dayjs/plugin/relativeTime"; import "./index.scss"; import { MainContentContainer } from "../../ui/atoms/page-content"; import { Paginator } from "../../ui/molecules/paginator"; import { useLocale } from "../../hooks"; dayjs.extend(relativeTime); interface DocumentPopularity { value: number; ranking: number; parentValue: number; parentRanking: number; } type CountByType = { [key: string]: number }; interface DocumentDifferences { countByType: CountByType; total: number; } interface DocumentEdits { modified: string; parentModified: string; commitURL: string; parentCommitURL: string; sourceCommitsBehindCount?: number; sourceCommitURL?: string; } interface Document { mdn_url: string; edits: DocumentEdits; title: string; popularity: DocumentPopularity; differences: DocumentDifferences; } interface Counts { found: number; total: number; pages: number; noParents: number; cacheMisses: number; } interface Times { took: number; } interface FlawLevel { name: string; level: string; ignored: boolean; } interface Data { counts: Counts; documents: Document[]; times: Times; flawLevels: FlawLevel[]; } interface LocaleStorageData { lastLoadTime?: number; defaultSort?: string; defaultSortReverse?: string; } interface StorageData { [locale: string]: LocaleStorageData; } const LOCALSTORAGE_KEY = "translations-dashboard-differences"; function saveStorage(locale: string, data: LocaleStorageData) { try { const stored = JSON.parse( localStorage.getItem(LOCALSTORAGE_KEY) || "{}" ) as StorageData; stored[locale] = Object.assign({}, stored[locale] || {}, data); localStorage.setItem(LOCALSTORAGE_KEY, JSON.stringify(stored)); } catch (err) { console.warn("Unable to save to localStorage", err); } } function getStorage(locale: string): LocaleStorageData | null { try { const stored = JSON.parse(localStorage.getItem(LOCALSTORAGE_KEY) || "{}"); if (stored) { return stored[locale] as LocaleStorageData; } } catch (err) { console.warn("Unable to retrieve from localStorage", err); } return null; } export function TranslationDifferences() { const locale = useLocale(); const [searchParams] = useSearchParams(); const navigate = useNavigate(); const [lastData, setLastData] = React.useState(null); React.useEffect(() => { if (locale.toLowerCase() === "en-us") { navigate(`/${locale}/_translations`); } }, [locale, navigate]); React.useEffect(() => { let title = "All translations"; if (locale.toLowerCase() !== "en-us") { title += ` for ${locale}`; } if (lastData) { title = `(${lastData.counts.found.toLocaleString()} found) ${title}`; } document.title = title; }, [lastData, locale]); const { data, error, isValidating } = useSWR( locale.toLowerCase() !== "en-us" ? `/_translations/differences/?locale=${locale}` : null, async (url) => { const response = await fetch(url); if (!response.ok) { throw new Error(`${response.status} (${await response.text()})`); } if ( !(response.headers.get("content-type") || "").includes( "application/json" ) ) { throw new Error( `Response is not JSON (${response.headers.get("content-type")})` ); } return response.json(); }, { revalidateOnFocus: false, } ); // Use this to be able to figure out how long the XHR takes when there's no cache const startTime = React.useRef(); React.useEffect(() => { if (locale) { if (!data) { startTime.current = new Date(); } else { if ( data.counts.cacheMisses > 0 && data.counts.cacheMisses === data.counts.total && startTime.current ) { const lastLoadTime = new Date().getTime() - startTime.current.getTime(); saveStorage(locale, { lastLoadTime }); } } } }, [locale, data]); React.useEffect(() => { if (data) { setLastData(data); } }, [data]); const lastStorageData = getStorage(locale); const defaultSort = lastStorageData?.defaultSort || "modified"; const defaultSortReverse = lastStorageData?.defaultSortReverse || "false"; const sort = searchParams.get("sort") || defaultSort; const sortReverse = JSON.parse( searchParams.get("sortReverse") || defaultSortReverse ); React.useEffect(() => { saveStorage( locale, Object.assign({}, lastStorageData, { defaultSort: sort, defaultSortReverse: sortReverse, }) ); }, [locale, sort, sortReverse, lastStorageData]); if (locale.toLowerCase() === "en-us") { return null; } return ( {lastData && !error && isValidating && (

Reloading...

)} {!data && !error && !lastData && ( )} {lastData && (

Go to{" "} Missing translations for {locale}

)} {error && } {lastData && (
)} {data && }
); } function Container({ children }: { children: React.ReactNode }) { return ( {children} ); } function ShowSearchError({ error }) { return (

Search error

{error.toString()}
); } function BuildTimes({ times }: { times: Times }) { function format(ms: number) { if (ms > 1000) { const s = ms / 1000; return `${s.toFixed(1)} seconds`; } else { return `${Math.trunc(ms)} milliseconds`; } } return (

Time to find all documents {format(times.took)}

); } function Loading({ estimate }: { estimate: number }) { const startLoadingTime = new Date(); const [estimateEndTime] = React.useState( new Date(startLoadingTime.getTime() + estimate) ); const [, setIncrements] = React.useState(0); React.useEffect(() => { const interval = setInterval(() => { setIncrements((state) => state + 1); }, 1000); return () => clearInterval(interval); }, []); const distance = estimateEndTime.getTime() - startLoadingTime.getTime(); const elapsed = new Date().getTime() - startLoadingTime.getTime(); const percent = (100 * elapsed) / distance; return (
{percent}%
Estimated time to finish: {((distance - elapsed) / 1000).toFixed(0)}s{" "} {elapsed > distance ? ( 🙃 ) : null}
); } function FilterControls() { const [searchParams, setSearchParams] = useSearchParams(); const [title, setTitle] = React.useState(searchParams.get("title") || ""); const [url, setURL] = React.useState(searchParams.get("url") || ""); const [differences, setDifferences] = React.useState( searchParams.get("differences") || "" ); function refreshFilters(reset = false) { const filterParams = createSearchParams(searchParams); for (const [key, value] of [ ["url", url], ["title", title], ["differences", differences], ]) { if (!reset && value) { filterParams.set(key, value); } else { filterParams.delete(key); } } filterParams.delete("page"); setSearchParams(filterParams); } function resetFilters() { setURL(""); setTitle(""); setDifferences(""); refreshFilters(true); } return (

Filters

{ event.preventDefault(); refreshFilters(); }} >

Document

{ setURL(event.target.value); }} onBlur={() => refreshFilters()} /> { setTitle(event.target.value); }} onBlur={() => refreshFilters()} />

Differences

{ setDifferences(event.target.value); }} onBlur={() => refreshFilters()} />

 

{" "} {(url || title) && ( )}
); } type NumericOperation = { operation: "eq" | "gt" | "gte" | "lt" | "lte"; value: number; }; function getNumericOperation(expression: string): NumericOperation | null { function parseIntOrThrow(v: string) { if (!v) { throw new Error("Can't be empty"); } const parsed = parseInt(v, 10); if (isNaN(parsed)) { throw new Error(`'${v}' not a valid number`); } return parsed; } try { if (expression.startsWith(">=")) { return { operation: "gte", value: parseIntOrThrow(expression.replace(">=", "")), }; } else if (expression.startsWith(">")) { return { operation: "gt", value: parseIntOrThrow(expression.replace(">", "")), }; } else if (expression.startsWith("<=")) { return { operation: "lte", value: parseIntOrThrow(expression.replace("<=", "")), }; } else if (expression.startsWith("<")) { return { operation: "lt", value: parseIntOrThrow(expression.replace("<", "")), }; } else if (expression.startsWith("==") || expression.startsWith("=")) { return { operation: "eq", value: parseIntOrThrow(expression.replace(/=/g, "")), }; } else if (expression) { return { operation: "eq", value: parseIntOrThrow(expression), }; } else { return null; } } catch (error) { console.warn(error); return null; } } function matchNumericOperation(value: number, op: NumericOperation): boolean { if (op.operation === "eq") { if (value !== op.value) { return false; } } else if (op.operation === "gt") { if (!(value > op.value)) { return false; } } else if (op.operation === "gte") { if (!(value >= op.value)) { return false; } } else if (op.operation === "lt") { if (!(value < op.value)) { return false; } } else if (op.operation === "lte") { if (!(value <= op.value)) { return false; } } else { throw new Error(`Not implemented operation '${op.operation}'`); } return true; } function DocumentsTable({ locale, counts, documents, sort, sortReverse, }: { locale: string; counts: Counts; documents: Document[]; sort: string; sortReverse: boolean; }) { const [searchParams, setSearchParams] = useSearchParams(); const page = parseInt(searchParams.get("page") || "1", 10); const pageSize = parseInt(searchParams.get("pageSize") || "20", 10); const filterTitle = searchParams.get("title") || ""; const filterURL = searchParams.get("url") || ""; const filterDifferences = searchParams.get("differences") || ""; const filterDifferencesOperation = getNumericOperation(filterDifferences); function TableHead({ id, title }: { id: string; title: string }) { return ( { if (sort === id) { setSearchParams( createSearchParams({ sort: id, sortReverse: JSON.stringify(!sortReverse), }) ); } else { setSearchParams(createSearchParams({ sort: id })); } }} className="sortable" > {title} {sort === id ? (sortReverse ? "↓" : "↑") : null} ); } const filteredDocuments = documents .filter((document) => { if ( filterTitle && !document.title.toLowerCase().includes(filterTitle.toLowerCase()) ) { return false; } if ( filterURL && !document.mdn_url.toLowerCase().includes(filterURL.toLowerCase()) ) { return false; } if ( filterDifferencesOperation && !matchNumericOperation( document.differences.total, filterDifferencesOperation ) ) { return false; } return true; }) .sort((A, B) => { let reverse = sortReverse ? -1 : 1; if (sort === "modified") { const a = new Date(A.edits.modified); const b = new Date(B.edits.modified); return reverse * (b.getTime() - a.getTime()); } else if (sort === "popularity") { const a = A.popularity.value; const b = B.popularity.value; return reverse * (b - a); } else if (sort === "differences") { const a = A.differences.total; const b = B.differences.total; return reverse * (b - a); } else if (sort === "title") { const a = A.title; const b = B.title; return reverse * a.localeCompare(b); } else if (sort === "mdn_url") { const a = A.mdn_url; const b = B.mdn_url; return reverse * a.localeCompare(b); } else if (sort === "sourceCommit") { const a = A.edits.sourceCommitsBehindCount ?? -1; const b = B.edits.sourceCommitsBehindCount ?? -1; return reverse * (b - a); } else { throw new Error(`Unrecognized sort '${sort}'`); } }); const pageCount = Math.ceil(counts.found / pageSize); return (

Documents with differences found ( {filteredDocuments.length.toLocaleString()}){" "} {pageCount > 1 && ( page {page}/{pageCount} )}

{counts.total.toLocaleString()} total documents found ({locale})

{filterDifferences && !filterDifferencesOperation && (

Invalid differences filter

The differences filter can't be parsed.
{filterDifferences}

)} {filteredDocuments .slice((page - 1) * pageSize, page * pageSize) .map((doc: Document) => { return ( ); })}
{filterTitle ? ( ) : ( doc.title )}
{!doc.popularity.ranking ? "n/a" : `${getGetOrdinal(doc.popularity.ranking)}`}{" "} ( {!doc.popularity.parentRanking ? "n/a" : `${getGetOrdinal(doc.popularity.parentRanking)}`} ) {doc.differences.total.toLocaleString()}
); } function L10nSourceCommitModified({ sourceCommitsBehindCount, sourceCommitURL, }: Pick) { if ( !sourceCommitURL || (!sourceCommitsBehindCount && sourceCommitsBehindCount !== 0) ) { return <>Metadata does not exist.; } const getImportanceColor = () => { if (sourceCommitsBehindCount === 0) return "🟢"; return sourceCommitsBehindCount < 10 ? "🟠" : "🔴"; }; return ( {`${getImportanceColor()} ${sourceCommitsBehindCount} commits behind`} ); } function LastModified({ edits }: { edits: DocumentEdits }) { const modified = dayjs(edits.modified); const parentModified = dayjs(edits.parentModified); return (
); } function HighlightedText({ text, highlight, }: { text: string; highlight: string; }) { // Split on highlight term and include term into parts, ignore case const parts = text.split(new RegExp(`(${highlight})`, "gi")); return ( {" "} {parts.map((part, i) => ( {part} ))}{" "} ); } function BriefURL({ uri, filterURL }: { uri: string; filterURL: string }) { const [left, right] = uri.split(/\/docs\//, 2); return ( <> {left}/docs/ {filterURL ? ( ) : ( right )} ); } // https://gist.github.com/jlbruno/1535691/db35b4f3af3dcbb42babc01541410f291a8e8fac function getGetOrdinal(n: number) { const s = ["th", "st", "nd", "rd"]; const v = n % 100; return n.toLocaleString() + (s[(v - 20) % 10] || s[v] || s[0]); }