import type { OAuthSession } from "@atproto/oauth-client-browser"; import { useMemo, useState } from "react"; import type { Catalog } from "../catalog"; import { type Holdings, shown } from "../collection/cards"; import { type Containers, UNFILED } from "../collection/containers"; import type { Field } from "../import/formats"; import { FORMATS, MANABOX } from "../import/formats"; import { useListed } from "../import/runner"; import { write } from "./csv"; import { rows, type Written } from "./rows"; import { ALL, named, plain, within } from "./scope"; export const EXPORT = "/collection/export"; // How long the file stays reachable after the press. const KEEP = 60_000; // What a column the format lacks would have carried, in the app's own words. const WORDS: Partial> = { container: "where cards are filed", condition: "what condition they are in", createdAt: "when they were added", updatedAt: "when a stack last changed", acquiredAt: "when you got the cards", note: "notes", proxy: "which cards are proxies", tags: "tags", price: "what you paid", currency: "what you paid it in", marketValue: "what a copy was worth", marketCurrency: "what that was worth in", }; // Which file to ask for, since the choice is what reads it rather than what // is in it. A format nobody described says nothing rather than breaking. const FOR: Record = { ManaBox: "what another tracker will read", Manaweb: "every column a record holds", }; // Your collection as a file another tracker reads, which is the half of // owning your data that having an account does not give you. export function Export({ session, owning, catalog, containers, }: { session: OAuthSession | null; owning: Holdings; catalog: Catalog | null; containers: Containers; }) { const [format, setFormat] = useState(MANABOX); const [scope, setScope] = useState(ALL); // Off by default: the file says what the card is until somebody decides // they would rather it said what the tracker will take. const [english, setEnglish] = useState(false); // An import puts cards in the repo hours before they are records, and a // listing that has not come back looks exactly like one holding nothing. const counted = useListed(); const places = useMemo( () => new Map(containers.held.map((one) => [one.uri, one.value.name])), [containers.held], ); const chosen = useMemo( () => within(owning.stacks, scope), [owning.stacks, scope], ); const ready = owning.fresh && counted; const written = useMemo( () => catalog && ready ? rows(chosen, format, (ids) => catalog.resolve(ids), { places, english, }) : null, [catalog, ready, chosen, format, places, english], ); const cards = chosen.reduce((sum, one) => sum + shown(one), 0); if (!session) { return

Sign in to export what you own.

; } return ( <>

A CSV of what you own, read here and written on this device. Nothing leaves your repository to make it.

event.preventDefault()}> {FORMATS.length > 1 && ( )}

{cards.toLocaleString()} card{cards === 1 ? "" : "s"} {FOR[format.name] && ( · {FOR[format.name]} )}

{!catalog ? (

The catalog that names your cards is not loaded, so there is nothing to write their names from.

) : owning.error ? (

Your collection did not come back: {owning.error}

) : !ready ? (

Reading your collection…

) : ( written && ( <> {written.refused > 0 && (

)} ) )} ); } // A list with the verb that agrees with it, which is why this is not a join. function said(fields: Field[]): string { const words = fields.map((field) => WORDS[field] ?? field); const last = words.pop() ?? ""; const all = words.length > 0 ? `${words.join(", ")} and ${last}` : last; return `${all} ${fields.length === 1 ? "is" : "are"}`; } // What the file will not say, while there is still a chance not to write it. function Losses({ written, format }: { written: Written; format: string }) { const lost = written.dropped .map((field) => WORDS[field] ?? field) .join(", "); return ( <>

{(written.rows.length - 1).toLocaleString()} rows, as {format}.

{lost && (

A {format} file has no column for {lost}, so this one says nothing about {written.dropped.length === 1 ? "it" : "them"}.

)} {written.unnamed > 0 && (

{written.unnamed.toLocaleString()} card {written.unnamed === 1 ? "" : "s"} the catalog does not know, so only the Scryfall id names {written.unnamed === 1 ? "it" : "them"}.

)} {written.unread.length > 0 && (

{said(written.unread)} written here, and reading this file back would not restore {written.unread.length === 1 ? "it" : "them"}.

)} {written.unkept > 0 && (

{written.unkept.toLocaleString()} card {written.unkept === 1 ? "" : "s"} say something a row carries and a read makes nothing of — a tag holding a comma, or a figure with no currency beside it.

)} {written.unspent > 0 && (

{written.unspent.toLocaleString()} purchase {written.unspent === 1 ? "" : "s"} the copies ran out before, so no row says what {written.unspent === 1 ? "it" : "they"} cost or when.

)} {written.unspelled > 0 && (

{written.unspelled.toLocaleString()} card {written.unspelled === 1 ? "" : "s"} in a finish {format} has no word for, which {written.unspelled === 1 ? "that row" : "those rows"} will not read back as.

)} ); } function save(written: Written, format: string, scope: string): void { const file = new Blob([write(written.rows)], { type: "text/csv;charset=utf-8", }); const url = URL.createObjectURL(file); const day = new Date().toISOString().slice(0, 10); const link = document.createElement("a"); link.href = url; link.download = `manaweb-${plain(scope)}-${plain(format)}-${day}.csv`; // In the document, and the blob kept well past the press: WebKit cancels a // download whose URL is revoked before it has committed, and says nothing. document.body.append(link); link.click(); link.remove(); setTimeout(() => URL.revokeObjectURL(url), KEEP); }