diff --git a/chess-client/src/components/SetupWizard.tsx b/chess-client/src/components/SetupWizard.tsx index f41812c..06d4175 100644 --- a/chess-client/src/components/SetupWizard.tsx +++ b/chess-client/src/components/SetupWizard.tsx @@ -1,11 +1,10 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useCallback } from "react"; import { open as openDialog } from "@tauri-apps/plugin-dialog"; import { useSidecarProgress } from "../hooks/useSidecarProgress"; import AddGameDialog from "./AddGameDialog"; import { ProfileSetupForm, loadMyPlayer, saveMyPlayer } from "./MyStatsWidget"; -import { TwicCredit, useTwicAck } from "./TwicCredit"; -import { getTwicFrom, setTwicFrom } from "../twicPrefs"; -import { PlayerInfo } from "../types"; +import { getSources, setSourceEnabled } from "../api"; +import { PlayerInfo, SourceStatus } from "../types"; interface Props { onClose: () => void; @@ -14,14 +13,14 @@ interface Props { onFinish?: () => void; } -type Step = "welcome" | "players" | "databases" | "twic" | "dedup" | "normalise" | "index" | "profile" | "done"; +type Step = "welcome" | "players" | "databases" | "sources" | "dedup" | "normalise" | "index" | "profile" | "done"; -const STEPS: Step[] = ["welcome", "players", "databases", "twic", "dedup", "normalise", "index", "profile", "done"]; +const STEPS: Step[] = ["welcome", "players", "databases", "sources", "dedup", "normalise", "index", "profile", "done"]; const STEP_LABELS: Record = { welcome: "Welcome", players: "Players", databases: "Databases", - twic: "TWIC", + sources: "Sources", dedup: "Dedup", normalise: "Names", index: "Index", @@ -29,7 +28,7 @@ const STEP_LABELS: Record = { done: "Summary", }; -const OPTIONAL_STEPS: Step[] = ["players", "databases", "twic", "dedup", "normalise", "index", "profile"]; +const OPTIONAL_STEPS: Step[] = ["players", "databases", "sources", "dedup", "normalise", "index", "profile"]; const STORAGE_KEY = "chess-setup-state"; interface PersistedState { @@ -147,7 +146,7 @@ function WelcomeStep({ onExpress, onAdvanced }: { > Express setup - Just the essentials: download and import TWIC, deduplicate, and build the + Just the essentials: populate the reference database, deduplicate, and build the position index. Skips the optional player-reference and existing-database steps. @@ -172,7 +171,7 @@ function WelcomeStep({ onExpress, onAdvanced }: { {[ "Import a player reference file", "Import your existing game collections", - "Download and import TWIC issues", + "Populate from curated reference sources", "Deduplicate the database", "Build the position index", ].map((text, i) => ( @@ -277,106 +276,269 @@ function DatabasesStep({ completed, onComplete, onRunningChange }: { completed: ); } -// ── Step: TWIC (download + import) ──────────────────────────────────────────── +// ── Step: Sources (populate the reference database) ─────────────────────────── +// +// Phase C2 (#98): onboarding is a simple binary — populate the reference +// database vs start empty — with all per-source detail deferred to +// Maintenance → Sources (C1). The "populate" path enables the chosen catalog +// sources (recording the attribution acknowledgment via the C1 `credit_acked` +// gate) and fires their sync jobs, which run on the daemon in the background; +// onboarding finishes immediately rather than blocking on a progress bar. + +type History = "free" | "commercial" | "none"; + +/** A non-commercial / restrictive licence we should visibly flag in the + * acknowledgment (e.g. CC BY-NC-SA sources like Lumbra's). Derived from the + * catalog credit line so new sources are flagged without extra metadata. */ +function isNonCommercial(credit: string): boolean { + return /non-?commercial|CC[\s-]?BY-NC|\bNC[\s-]?SA\b/i.test(credit); +} -/** Download/import progress block, shared by the two TWIC operations. - * `cancellable` is false for the fast (appender) import, which must not be - * interrupted — doing so can corrupt the database. */ -function StepProgress({ progress, label, cancellable = true }: { progress: ReturnType; label: string; cancellable?: boolean }) { +/** One acknowledgment row: tick to accept a source's attribution/licence. */ +function AckRow({ source, checked, onChange }: { source: SourceStatus; checked: boolean; onChange: (v: boolean) => void }) { + const nc = isNonCommercial(source.credit); return ( -
-
- {progress.done ? "Complete" : label} - {Math.round(progress.percent)}% -
- - {progress.done &&

✓ {progress.doneMessage}

} - {progress.running && !progress.done && cancellable && ( -
- -
- )} -
+ ); } -// Download and import TWIC in a single step (mirrors the Maintenance TWIC box). -// The step counts as complete once issues are imported into the database. -function TwicStep({ completed, onComplete, onRunningChange }: { completed: boolean; onComplete: () => void; onRunningChange: (r: boolean) => void }) { - // Shared with the Maintenance panel so the starting issue stays in sync. - const [fromIssue, setFromIssue] = useState(getTwicFrom()); +// Replaces the old inline "download + import TWIC with a progress bar" step. +// The step completes as soon as the chosen sources are enabled and their +// background sync jobs are submitted — the daemon does the work afterwards. +function SourcesStep({ completed, onComplete, onRunningChange }: { completed: boolean; onComplete: () => void; onRunningChange: (r: boolean) => void }) { const [rerunning, setRerunning] = useState(false); - const [twicAck, setTwicAck] = useTwicAck(); - const download = useSidecarProgress(); - const importProgress = useSidecarProgress(); + const [sources, setSources] = useState(null); + const [loadError, setLoadError] = useState(null); + const [mode, setMode] = useState<"choice" | "configure">("choice"); + const [history, setHistory] = useState("free"); + const [acked, setAcked] = useState>(new Set()); + const [enabling, setEnabling] = useState(false); + const [importing, setImporting] = useState(false); + const [submitError, setSubmitError] = useState(null); + + const loadSources = useCallback(() => { + setLoadError(null); + setSources(null); + getSources() + .then((s) => { setSources(s); setAcked(new Set(s.filter((x) => x.credit_acked).map((x) => x.key))); }) + .catch((e: unknown) => setLoadError(String(e))); + }, []); + useEffect(() => { loadSources(); }, [loadSources]); + + // Report this step's combined busy state to the wizard: the brief enable + // loop OR an in-progress commercial PGN import. Reporting the OR (rather than + // letting each writer set the shared flag directly) stops the enable loop's + // completion from clearing the "operation in progress" close guard while a + // commercial import is still running. + useEffect(() => { onRunningChange(enabling || importing); }, [enabling, importing, onRunningChange]); - useEffect(() => { if (importProgress.done) onComplete(); }, [importProgress.done]); - useEffect(() => { onRunningChange(download.running || importProgress.running); }, [download.running, importProgress.running]); + if (completed && !rerunning) { + return { setRerunning(true); setMode("choice"); }} />; + } - // No --dir: the server downloads/imports into its own data dir - // (data_root()/twic) — passing a client path is wrong for the system daemon. - function runDownload() { - void download.run(["download", "--from", fromIssue]); + // Currency = the auto-updating feeds; deep history = the bulk archives. + const feeds = sources?.filter((s) => s.kind === "feed") ?? []; + const bulk = sources?.filter((s) => s.kind === "bulk") ?? []; + // Sources enabled by the primary button: the feeds always, plus the free + // historical base when chosen. (Commercial = the user's own local PGN import; + // None = feeds only.) + const selected = [...feeds, ...(history === "free" ? bulk : [])]; + const allAcked = selected.every((s) => acked.has(s.key)); + + function toggleAck(key: string, on: boolean) { + setAcked((prev) => { const next = new Set(prev); if (on) next.add(key); else next.delete(key); return next; }); } - function runImport() { - // --fast = appender-based bulk inserts (much quicker). It must not be - // interrupted, so the import progress below is rendered non-cancelable. - void importProgress.run(["import", "--fast"]); + + function chooseEmpty() { + // Empty database: nothing to enable (sources default to off). Just finish. + onComplete(); } - if (completed && !rerunning) { - return setRerunning(true)} />; + async function enableAndImport() { + setSubmitError(null); + setEnabling(true); + try { + // Just enable each chosen source (recording the acknowledgment in the same + // step, per the C1 credit_acked gate). The daemon's scheduler picks up + // enabled-but-not-yet-synced sources and imports them in the background + // (#40 C3), so onboarding doesn't submit or wait on any sync itself. + for (const s of selected) { + await setSourceEnabled(s.key, true, true); + } + onComplete(); + } catch (e: unknown) { + // Surface the failure and stay on the step rather than silently dropping + // it. Earlier sources in the loop may already be enabled; re-clicking is + // safe (enabling is idempotent). + setSubmitError(`Couldn’t enable the selected sources: ${String(e)} — some may already be enabled; you can retry.`); + } finally { + setEnabling(false); + } } - const filledBtn = "w-full h-10 rounded-full bg-primary text-on-primary text-label-lg hover:brightness-110 active:brightness-95 transition-all duration-short3 ease-standard"; + if (loadError !== null) { + return ( +
+

Couldn’t load the source catalog: {loadError}

+ +
+ ); + } + if (sources === null) { + return

Loading sources…

; + } + + // ── View 1: the binary choice ── + if (mode === "choice") { + return ( +
+

+ Populate your database with curated reference collections, or start empty and import only + your own games. You can change any of this later under Maintenance → Sources. +

+
+ + +
+
+ ); + } + + // ── View 2: configure the populate path ── + const filledBtn = "w-full h-10 rounded-full bg-primary text-on-primary text-label-lg hover:brightness-110 active:brightness-95 disabled:opacity-40 transition-all duration-short3 ease-standard"; + const historyOptions: { value: History; label: string; hint: string }[] = [ + { value: "free", label: "Free historical base", hint: bulk.map((s) => s.name).join(", ") || "A free public archive of older games." }, + { value: "commercial", label: "I own a commercial database", hint: "e.g. ChessBase Megabase — export to PGN and import it below." }, + { value: "none", label: "None", hint: "Just the live feeds for now." }, + ]; return ( -
-

- Download TWIC (The Week in Chess) issues and import them into your database, in one place. Issues already present or imported are skipped automatically. -

- +
+ - {/* Download */} -
-
Download
-
-
Download from issue
- { setFromIssue(e.target.value); setTwicFrom(e.target.value); }} - disabled={download.running || download.done} - className="w-32 h-10 px-3 rounded-sm bg-transparent text-on-surface text-body-md font-mono border border-outline focus:outline-none focus:border-primary disabled:opacity-50 transition-colors duration-short3 ease-standard" - /> -

Issues are available from 920 onwards.

+ {/* Currency — the auto-updating feeds, always part of populate. */} +
+
Keep current automatically
+

+ Recent tournament games, refreshed in the background. Acknowledge each source to enable it. +

+
+ {feeds.map((s) => ( + toggleAck(s.key, v)} /> + ))}
- {!download.running && !download.done && ( - twicAck ? ( - - ) : ( -

Tick “I've read this” above to enable the download.

- ) +
+ + {/* Deep history — Free / Commercial / None. */} +
+
Deep history
+

+ An optional historical base beneath the live feeds. Overlap is deduplicated automatically. +

+
+ {historyOptions.map((opt) => ( + + ))} +
+ {history === "free" && bulk.length > 0 && ( +
+ {bulk.map((s) => ( + toggleAck(s.key, v)} /> + ))} +
)} - {(download.running || download.done) && } -
+ {history === "commercial" && ( +
+

+ Export your database to PGN, then import it here. You can also do this later, and cap the + live feeds to start after its cutoff under Maintenance → Sources. +

+ { /* wizard handles navigation */ }} + onImported={onComplete} + onRunningChange={setImporting} + /> +
+ )} + - {/* Import */} -
-
Import into database
- {!importProgress.running && !importProgress.done && ( - download.done ? ( - - ) : ( -

Download issues first to enable the import.

- ) +
+ + {selected.length === 0 ? ( +

+ No reference sources to enable.{history === "commercial" ? " Import your database above to populate it." : ""} +

+ ) : !allAcked ? ( +

Acknowledge each selected source above to continue.

+ ) : null} + {submitError &&

{submitError}

} + {history === "commercial" && ( +

+ This enables the live feeds only — import your commercial database separately above (now or later). +

)} - {(importProgress.running || importProgress.done) && } +

+ ⓘ Importing runs on the daemon — you can finish setup and even close LPDO; it keeps going. +

); @@ -611,7 +773,8 @@ function DoneStep() {

Your chess database is ready to use.

- Download newer TWIC issues and run maintenance operations at any time from the Setup panel in the header. + Manage your reference sources and run maintenance operations at any time from + Maintenance → Sources, or re-open this wizard from the header.

); @@ -658,7 +821,7 @@ export default function SetupWizard({ onClose, onFinish }: Props) { function back() { if (stepIndex > 0) setStepIndex((i) => i - 1); } // Welcome choices. Advanced walks every step; Express marks the two optional - // import steps (players, databases) as skipped and jumps straight to TWIC. + // import steps (players, databases) as skipped and jumps straight to Sources. function startAdvanced() { markComplete("welcome"); next(); @@ -666,7 +829,7 @@ export default function SetupWizard({ onClose, onFinish }: Props) { function startExpress() { markComplete("welcome"); setSkippedSteps((prev) => new Set([...prev, "players", "databases"])); - setStepIndex(STEPS.indexOf("twic")); + setStepIndex(STEPS.indexOf("sources")); } const stepProps = (s: Step) => ({ @@ -719,7 +882,7 @@ export default function SetupWizard({ onClose, onFinish }: Props) { {step === "welcome" && } {step === "players" && } {step === "databases" && } - {step === "twic" && } + {step === "sources" && } {step === "dedup" && } {step === "normalise" && } {step === "index" && }