Library import asks for a location to file the cards under
After picking a file, Import cards… asks where the cards are kept, pre-filled with the file's name (a ManaBox export is named after its binder). A location is set on every imported card, replacing any it had, as Add Cards does; blank leaves locations alone. It's applied inside the import's single transaction.
This commit is contained in:
@@ -237,7 +237,10 @@ match any printing still imports the card itself.
|
||||
or binder, either the `.txt` or the `.csv`. Both add to what you already own.
|
||||
The CSV names each copy's exact printing and finish, so it's the better of
|
||||
the two. Condition, language, prices and the other ManaBox fields aren't
|
||||
kept. Proxies count as owned copies.
|
||||
kept. Proxies count as owned copies. After you pick the file, the import asks
|
||||
for a location to file the cards under, pre-filled with the file's name (a
|
||||
ManaBox export is named after its binder). Leave it blank to keep cards'
|
||||
current locations.
|
||||
|
||||
> The picker's dates and languages need card data from this version. Until you
|
||||
> update it, printings are listed without them. Whenever your card data is
|
||||
|
||||
@@ -456,15 +456,24 @@ impl Catalog {
|
||||
|
||||
/// Import a card list into the library: a plain-text list (ours, or
|
||||
/// ManaBox's text export) or a ManaBox CSV export, told apart by
|
||||
/// [`crate::manabox::is_manabox_csv`]. The whole import is one
|
||||
/// transaction, so a failure part-way leaves the library as it was.
|
||||
/// `on_progress(done, total)` is called after each line.
|
||||
pub fn import_card_list(&self, text: &str, mut on_progress: impl FnMut(usize, usize)) -> Result<LibraryImport> {
|
||||
/// [`crate::manabox::is_manabox_csv`]. A non-blank `location` becomes
|
||||
/// the storage location of every card imported, replacing any it had,
|
||||
/// as adding a card on Add Cards with a location does; a blank one
|
||||
/// leaves locations alone. The whole import is one transaction, so a
|
||||
/// failure part-way leaves the library as it was. `on_progress(done,
|
||||
/// total)` is called after each line.
|
||||
pub fn import_card_list(
|
||||
&self,
|
||||
text: &str,
|
||||
location: &str,
|
||||
mut on_progress: impl FnMut(usize, usize),
|
||||
) -> Result<LibraryImport> {
|
||||
let location = location.trim();
|
||||
let tx = self.nested_tx()?;
|
||||
let result = if crate::manabox::is_manabox_csv(text) {
|
||||
self.import_manabox_rows(&crate::manabox::parse_manabox_csv(text)?, &mut on_progress)?
|
||||
self.import_manabox_rows(&crate::manabox::parse_manabox_csv(text)?, location, &mut on_progress)?
|
||||
} else {
|
||||
self.import_library_lines(&parse_library_text(text), &mut on_progress)?
|
||||
self.import_library_lines(&parse_library_text(text), location, &mut on_progress)?
|
||||
};
|
||||
tx.commit()?;
|
||||
Ok(result)
|
||||
@@ -479,14 +488,18 @@ impl Catalog {
|
||||
/// new_total_quantity_owned)` per applied line, plus unresolved names.
|
||||
pub fn import_library_list(&self, text: &str) -> Result<LibraryImport> {
|
||||
let tx = self.nested_tx()?;
|
||||
let result = self.import_library_lines(&parse_library_text(text), &mut |_, _| {})?;
|
||||
let result = self.import_library_lines(&parse_library_text(text), "", &mut |_, _| {})?;
|
||||
tx.commit()?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// The import loop behind [`Catalog::import_card_list`] and
|
||||
/// [`Catalog::import_library_list`]; `location` is set on each imported
|
||||
/// card unless empty.
|
||||
fn import_library_lines(
|
||||
&self,
|
||||
lines: &[DecklistLine],
|
||||
location: &str,
|
||||
on_progress: &mut dyn FnMut(usize, usize),
|
||||
) -> Result<LibraryImport> {
|
||||
let mut added = Vec::new();
|
||||
@@ -498,6 +511,9 @@ impl Catalog {
|
||||
continue;
|
||||
};
|
||||
let new_qty = self.add_owned(&oid, line.quantity)?;
|
||||
if !location.is_empty() {
|
||||
self.set_location(&oid, location)?;
|
||||
}
|
||||
if let Some(scryfall_id) = printing {
|
||||
let wanted = line.printing.as_ref().and_then(|p| p.finish.clone());
|
||||
self.add_imported_copies(&oid, &scryfall_id, wanted, line.quantity)?;
|
||||
@@ -664,17 +680,41 @@ mod tests {
|
||||
let cat = &fx.catalog;
|
||||
let mut seen = Vec::new();
|
||||
let (added, _) = cat
|
||||
.import_card_list("2 Lightning Bolt\n1 Sol Ring\n", |done, total| seen.push((done, total)))
|
||||
.import_card_list("2 Lightning Bolt\n1 Sol Ring\n", "", |done, total| seen.push((done, total)))
|
||||
.unwrap();
|
||||
assert_eq!(added.len(), 2);
|
||||
assert_eq!(seen, vec![(0, 2), (1, 2), (2, 2)]);
|
||||
|
||||
let csv = "Name,Quantity,Scryfall ID\nSerra Angel,3,\n";
|
||||
let (added, unresolved) = cat.import_card_list(csv, |_, _| {}).unwrap();
|
||||
let (added, unresolved) = cat.import_card_list(csv, "", |_, _| {}).unwrap();
|
||||
assert!(unresolved.is_empty());
|
||||
assert_eq!(added, vec![("Serra Angel".to_string(), 3)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_card_list_files_imported_cards_under_a_location() {
|
||||
let fx = test_support::make();
|
||||
let cat = &fx.catalog;
|
||||
cat.add_owned("sol-ring", 1).unwrap();
|
||||
cat.set_location("sol-ring", "Box 1").unwrap();
|
||||
cat.add_owned("serra-angel", 1).unwrap();
|
||||
cat.set_location("serra-angel", "Box 1").unwrap();
|
||||
let location = |oid: &str| {
|
||||
cat.list_library().unwrap().into_iter().find(|c| c.card.oracle_id == oid).unwrap().location
|
||||
};
|
||||
|
||||
// Blank leaves every location alone.
|
||||
cat.import_card_list("1 Sol Ring\n", " ", |_, _| {}).unwrap();
|
||||
assert_eq!(location("sol-ring"), "Box 1");
|
||||
|
||||
// Otherwise it's set on, and only on, the imported cards.
|
||||
let csv = "Name,Quantity,Scryfall ID\nSol Ring,1,\nLightning Bolt,2,\n";
|
||||
cat.import_card_list(csv, " Green Bulk ", |_, _| {}).unwrap();
|
||||
assert_eq!(location("sol-ring"), "Green Bulk");
|
||||
assert_eq!(location("lightning-bolt"), "Green Bulk");
|
||||
assert_eq!(location("serra-angel"), "Box 1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writes_inside_an_uncommitted_nested_tx_are_rolled_back() {
|
||||
let fx = test_support::make();
|
||||
|
||||
@@ -180,14 +180,18 @@ impl Catalog {
|
||||
/// [`Catalog::import_card_list`], which reports progress.
|
||||
pub fn import_manabox_csv(&self, text: &str) -> Result<LibraryImport> {
|
||||
let tx = self.nested_tx()?;
|
||||
let result = self.import_manabox_rows(&parse_manabox_csv(text)?, &mut |_, _| {})?;
|
||||
let result = self.import_manabox_rows(&parse_manabox_csv(text)?, "", &mut |_, _| {})?;
|
||||
tx.commit()?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// The import loop behind [`Catalog::import_manabox_csv`] and
|
||||
/// [`Catalog::import_card_list`]; `location` is set on each imported
|
||||
/// card unless empty.
|
||||
pub(crate) fn import_manabox_rows(
|
||||
&self,
|
||||
rows: &[ManaboxRow],
|
||||
location: &str,
|
||||
on_progress: &mut dyn FnMut(usize, usize),
|
||||
) -> Result<LibraryImport> {
|
||||
let mut added = Vec::new();
|
||||
@@ -199,6 +203,9 @@ impl Catalog {
|
||||
continue;
|
||||
};
|
||||
let new_qty = self.add_owned(&oid, row.quantity)?;
|
||||
if !location.is_empty() {
|
||||
self.set_location(&oid, location)?;
|
||||
}
|
||||
if let Some(scryfall_id) = printing {
|
||||
self.add_imported_copies(&oid, &scryfall_id, row.finish.clone(), row.quantity)?;
|
||||
}
|
||||
|
||||
@@ -441,11 +441,16 @@ pub struct LibraryImportProgress {
|
||||
pub total: usize,
|
||||
}
|
||||
|
||||
/// Import a card list (plain text or a ManaBox export) into the library.
|
||||
/// Import a card list (plain text or a ManaBox export) into the library,
|
||||
/// filing every imported card under `location` unless it's blank.
|
||||
/// Runs on a blocking thread so the UI stays responsive, emitting
|
||||
/// `library-import-progress` as it goes.
|
||||
#[tauri::command]
|
||||
pub async fn import_library_cards(app: AppHandle, path: String) -> Res<LibraryImportResult> {
|
||||
pub async fn import_library_cards(
|
||||
app: AppHandle,
|
||||
path: String,
|
||||
location: Option<String>,
|
||||
) -> Res<LibraryImportResult> {
|
||||
crate::sync::blocking(move || {
|
||||
let text = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
|
||||
let s = crate::sync::lock(&app);
|
||||
@@ -453,7 +458,7 @@ pub async fn import_library_cards(app: AppHandle, path: String) -> Res<LibraryIm
|
||||
let mut last_percent = None;
|
||||
let (added, unresolved) = s
|
||||
.catalog()?
|
||||
.import_card_list(&text, |done, total| {
|
||||
.import_card_list(&text, location.as_deref().unwrap_or(""), |done, total| {
|
||||
let percent = (done * 100).checked_div(total).unwrap_or(100);
|
||||
if last_percent != Some(percent) {
|
||||
last_percent = Some(percent);
|
||||
|
||||
@@ -517,6 +517,7 @@ export const api = {
|
||||
invoke<DeckImportResult>("import_deck", { path, name }),
|
||||
exportLibraryCards: (oracleIds: string[], path: string) =>
|
||||
invoke<void>("export_library_cards", { oracleIds, path }),
|
||||
importLibraryCards: (path: string) =>
|
||||
invoke<LibraryImportResult>("import_library_cards", { path }),
|
||||
/** `location`, unless blank, is set on every imported card. */
|
||||
importLibraryCards: (path: string, location: string) =>
|
||||
invoke<LibraryImportResult>("import_library_cards", { path, location }),
|
||||
};
|
||||
|
||||
@@ -101,6 +101,8 @@ export function LibraryBrowser({
|
||||
const [importError, setImportError] = useState<string | null>(null);
|
||||
// The "what can I import?" explainer shown before the file picker.
|
||||
const [importIntroOpen, setImportIntroOpen] = useState(false);
|
||||
// The file picked to import, while asking where its cards are kept.
|
||||
const [importPath, setImportPath] = useState<string | null>(null);
|
||||
// Lines done / total while an import runs; null when idle.
|
||||
const [importProgress, setImportProgress] = useState<{ done: number; total: number } | null>(null);
|
||||
// Only show the progress dialog for an import that's taking a while, so a
|
||||
@@ -322,14 +324,18 @@ export function LibraryBrowser({
|
||||
title: "Import library cards",
|
||||
filters: [{ name: "Card list (decklist or ManaBox export)", extensions: ["txt", "csv"] }],
|
||||
});
|
||||
if (typeof path !== "string") return;
|
||||
if (typeof path === "string") setImportPath(path);
|
||||
}
|
||||
|
||||
async function runImport(path: string, location: string) {
|
||||
setImportPath(null);
|
||||
setImportProgress({ done: 0, total: 0 });
|
||||
const reveal = setTimeout(() => setShowImportProgress(true), 300);
|
||||
const unlisten = await listen<{ done: number; total: number }>("library-import-progress", (e) =>
|
||||
setImportProgress(e.payload),
|
||||
);
|
||||
try {
|
||||
setImportResult(await api.importLibraryCards(path));
|
||||
setImportResult(await api.importLibraryCards(path, location));
|
||||
} catch (err) {
|
||||
setImportError(String(err));
|
||||
} finally {
|
||||
@@ -603,7 +609,10 @@ export function LibraryBrowser({
|
||||
choice. ManaBox's condition, language and price fields aren't kept.
|
||||
</li>
|
||||
</ul>
|
||||
<p className="dialog-message">Any cards that can't be matched are listed afterwards.</p>
|
||||
<p className="dialog-message">
|
||||
After you choose a file, you can file its cards under a location, like the binder they
|
||||
came from. Any cards that can't be matched are listed afterwards.
|
||||
</p>
|
||||
<div className="dialog-actions">
|
||||
<button onClick={() => setImportIntroOpen(false)}>Cancel</button>
|
||||
<button className="primary" onClick={pickImportFile} autoFocus>
|
||||
@@ -613,6 +622,14 @@ export function LibraryBrowser({
|
||||
</DialogFrame>
|
||||
)}
|
||||
|
||||
{importPath && (
|
||||
<ImportLocationDialog
|
||||
path={importPath}
|
||||
onImport={(location) => runImport(importPath, location)}
|
||||
onCancel={() => setImportPath(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{importProgress && showImportProgress && (
|
||||
<ProgressDialog
|
||||
title="Importing cards…"
|
||||
@@ -795,3 +812,60 @@ export function LibraryBrowser({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A picked file's name without its folder or extension — a ManaBox export
|
||||
* is named after its binder ("Green Bulk.csv"). Copes with Android content
|
||||
* URIs, whose last segment is a percent-encoded "primary:Download/x.csv". */
|
||||
function fileStem(path: string): string {
|
||||
let name = path;
|
||||
try {
|
||||
name = decodeURIComponent(path);
|
||||
} catch {
|
||||
// Not percent-encoded after all; use it as it is.
|
||||
}
|
||||
name = name.split(/[/\\:]/).pop() ?? "";
|
||||
return name.replace(/\.[^.]*$/, "").trim();
|
||||
}
|
||||
|
||||
/** Asks where an import's cards are kept, pre-filled with the file's name.
|
||||
* Blank imports them without touching any card's location. */
|
||||
function ImportLocationDialog({
|
||||
path,
|
||||
onImport,
|
||||
onCancel,
|
||||
}: {
|
||||
path: string;
|
||||
onImport: (location: string) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const [value, setValue] = useState(() => fileStem(path));
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
useEffect(() => inputRef.current?.select(), []);
|
||||
const submit = () => onImport(value.trim());
|
||||
|
||||
return (
|
||||
<DialogFrame title="Where are these cards?" onCancel={onCancel}>
|
||||
<label className="dialog-field">
|
||||
<span className="muted">Location</span>
|
||||
<input
|
||||
ref={inputRef}
|
||||
autoFocus
|
||||
value={value}
|
||||
placeholder="e.g. Box 1"
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && submit()}
|
||||
/>
|
||||
</label>
|
||||
<p className="dialog-message">
|
||||
Every imported card is filed under this location, replacing any it already had. Leave it
|
||||
blank to keep cards' current locations.
|
||||
</p>
|
||||
<div className="dialog-actions">
|
||||
<button onClick={onCancel}>Cancel</button>
|
||||
<button className="primary" onClick={submit}>
|
||||
Import
|
||||
</button>
|
||||
</div>
|
||||
</DialogFrame>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user