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.
|
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 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
|
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
|
> 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
|
> 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
|
/// 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
|
/// ManaBox's text export) or a ManaBox CSV export, told apart by
|
||||||
/// [`crate::manabox::is_manabox_csv`]. The whole import is one
|
/// [`crate::manabox::is_manabox_csv`]. A non-blank `location` becomes
|
||||||
/// transaction, so a failure part-way leaves the library as it was.
|
/// the storage location of every card imported, replacing any it had,
|
||||||
/// `on_progress(done, total)` is called after each line.
|
/// as adding a card on Add Cards with a location does; a blank one
|
||||||
pub fn import_card_list(&self, text: &str, mut on_progress: impl FnMut(usize, usize)) -> Result<LibraryImport> {
|
/// 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 tx = self.nested_tx()?;
|
||||||
let result = if crate::manabox::is_manabox_csv(text) {
|
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 {
|
} 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()?;
|
tx.commit()?;
|
||||||
Ok(result)
|
Ok(result)
|
||||||
@@ -479,14 +488,18 @@ impl Catalog {
|
|||||||
/// new_total_quantity_owned)` per applied line, plus unresolved names.
|
/// new_total_quantity_owned)` per applied line, plus unresolved names.
|
||||||
pub fn import_library_list(&self, text: &str) -> Result<LibraryImport> {
|
pub fn import_library_list(&self, text: &str) -> Result<LibraryImport> {
|
||||||
let tx = self.nested_tx()?;
|
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()?;
|
tx.commit()?;
|
||||||
Ok(result)
|
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(
|
fn import_library_lines(
|
||||||
&self,
|
&self,
|
||||||
lines: &[DecklistLine],
|
lines: &[DecklistLine],
|
||||||
|
location: &str,
|
||||||
on_progress: &mut dyn FnMut(usize, usize),
|
on_progress: &mut dyn FnMut(usize, usize),
|
||||||
) -> Result<LibraryImport> {
|
) -> Result<LibraryImport> {
|
||||||
let mut added = Vec::new();
|
let mut added = Vec::new();
|
||||||
@@ -498,6 +511,9 @@ impl Catalog {
|
|||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let new_qty = self.add_owned(&oid, line.quantity)?;
|
let new_qty = self.add_owned(&oid, line.quantity)?;
|
||||||
|
if !location.is_empty() {
|
||||||
|
self.set_location(&oid, location)?;
|
||||||
|
}
|
||||||
if let Some(scryfall_id) = printing {
|
if let Some(scryfall_id) = printing {
|
||||||
let wanted = line.printing.as_ref().and_then(|p| p.finish.clone());
|
let wanted = line.printing.as_ref().and_then(|p| p.finish.clone());
|
||||||
self.add_imported_copies(&oid, &scryfall_id, wanted, line.quantity)?;
|
self.add_imported_copies(&oid, &scryfall_id, wanted, line.quantity)?;
|
||||||
@@ -664,17 +680,41 @@ mod tests {
|
|||||||
let cat = &fx.catalog;
|
let cat = &fx.catalog;
|
||||||
let mut seen = Vec::new();
|
let mut seen = Vec::new();
|
||||||
let (added, _) = cat
|
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();
|
.unwrap();
|
||||||
assert_eq!(added.len(), 2);
|
assert_eq!(added.len(), 2);
|
||||||
assert_eq!(seen, vec![(0, 2), (1, 2), (2, 2)]);
|
assert_eq!(seen, vec![(0, 2), (1, 2), (2, 2)]);
|
||||||
|
|
||||||
let csv = "Name,Quantity,Scryfall ID\nSerra Angel,3,\n";
|
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!(unresolved.is_empty());
|
||||||
assert_eq!(added, vec![("Serra Angel".to_string(), 3)]);
|
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]
|
#[test]
|
||||||
fn writes_inside_an_uncommitted_nested_tx_are_rolled_back() {
|
fn writes_inside_an_uncommitted_nested_tx_are_rolled_back() {
|
||||||
let fx = test_support::make();
|
let fx = test_support::make();
|
||||||
|
|||||||
@@ -180,14 +180,18 @@ impl Catalog {
|
|||||||
/// [`Catalog::import_card_list`], which reports progress.
|
/// [`Catalog::import_card_list`], which reports progress.
|
||||||
pub fn import_manabox_csv(&self, text: &str) -> Result<LibraryImport> {
|
pub fn import_manabox_csv(&self, text: &str) -> Result<LibraryImport> {
|
||||||
let tx = self.nested_tx()?;
|
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()?;
|
tx.commit()?;
|
||||||
Ok(result)
|
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(
|
pub(crate) fn import_manabox_rows(
|
||||||
&self,
|
&self,
|
||||||
rows: &[ManaboxRow],
|
rows: &[ManaboxRow],
|
||||||
|
location: &str,
|
||||||
on_progress: &mut dyn FnMut(usize, usize),
|
on_progress: &mut dyn FnMut(usize, usize),
|
||||||
) -> Result<LibraryImport> {
|
) -> Result<LibraryImport> {
|
||||||
let mut added = Vec::new();
|
let mut added = Vec::new();
|
||||||
@@ -199,6 +203,9 @@ impl Catalog {
|
|||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let new_qty = self.add_owned(&oid, row.quantity)?;
|
let new_qty = self.add_owned(&oid, row.quantity)?;
|
||||||
|
if !location.is_empty() {
|
||||||
|
self.set_location(&oid, location)?;
|
||||||
|
}
|
||||||
if let Some(scryfall_id) = printing {
|
if let Some(scryfall_id) = printing {
|
||||||
self.add_imported_copies(&oid, &scryfall_id, row.finish.clone(), row.quantity)?;
|
self.add_imported_copies(&oid, &scryfall_id, row.finish.clone(), row.quantity)?;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -441,11 +441,16 @@ pub struct LibraryImportProgress {
|
|||||||
pub total: usize,
|
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
|
/// Runs on a blocking thread so the UI stays responsive, emitting
|
||||||
/// `library-import-progress` as it goes.
|
/// `library-import-progress` as it goes.
|
||||||
#[tauri::command]
|
#[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 || {
|
crate::sync::blocking(move || {
|
||||||
let text = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
|
let text = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
|
||||||
let s = crate::sync::lock(&app);
|
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 mut last_percent = None;
|
||||||
let (added, unresolved) = s
|
let (added, unresolved) = s
|
||||||
.catalog()?
|
.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);
|
let percent = (done * 100).checked_div(total).unwrap_or(100);
|
||||||
if last_percent != Some(percent) {
|
if last_percent != Some(percent) {
|
||||||
last_percent = Some(percent);
|
last_percent = Some(percent);
|
||||||
|
|||||||
@@ -517,6 +517,7 @@ export const api = {
|
|||||||
invoke<DeckImportResult>("import_deck", { path, name }),
|
invoke<DeckImportResult>("import_deck", { path, name }),
|
||||||
exportLibraryCards: (oracleIds: string[], path: string) =>
|
exportLibraryCards: (oracleIds: string[], path: string) =>
|
||||||
invoke<void>("export_library_cards", { oracleIds, path }),
|
invoke<void>("export_library_cards", { oracleIds, path }),
|
||||||
importLibraryCards: (path: string) =>
|
/** `location`, unless blank, is set on every imported card. */
|
||||||
invoke<LibraryImportResult>("import_library_cards", { path }),
|
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);
|
const [importError, setImportError] = useState<string | null>(null);
|
||||||
// The "what can I import?" explainer shown before the file picker.
|
// The "what can I import?" explainer shown before the file picker.
|
||||||
const [importIntroOpen, setImportIntroOpen] = useState(false);
|
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.
|
// Lines done / total while an import runs; null when idle.
|
||||||
const [importProgress, setImportProgress] = useState<{ done: number; total: number } | null>(null);
|
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
|
// 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",
|
title: "Import library cards",
|
||||||
filters: [{ name: "Card list (decklist or ManaBox export)", extensions: ["txt", "csv"] }],
|
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 });
|
setImportProgress({ done: 0, total: 0 });
|
||||||
const reveal = setTimeout(() => setShowImportProgress(true), 300);
|
const reveal = setTimeout(() => setShowImportProgress(true), 300);
|
||||||
const unlisten = await listen<{ done: number; total: number }>("library-import-progress", (e) =>
|
const unlisten = await listen<{ done: number; total: number }>("library-import-progress", (e) =>
|
||||||
setImportProgress(e.payload),
|
setImportProgress(e.payload),
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
setImportResult(await api.importLibraryCards(path));
|
setImportResult(await api.importLibraryCards(path, location));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setImportError(String(err));
|
setImportError(String(err));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -603,7 +609,10 @@ export function LibraryBrowser({
|
|||||||
choice. ManaBox's condition, language and price fields aren't kept.
|
choice. ManaBox's condition, language and price fields aren't kept.
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</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">
|
<div className="dialog-actions">
|
||||||
<button onClick={() => setImportIntroOpen(false)}>Cancel</button>
|
<button onClick={() => setImportIntroOpen(false)}>Cancel</button>
|
||||||
<button className="primary" onClick={pickImportFile} autoFocus>
|
<button className="primary" onClick={pickImportFile} autoFocus>
|
||||||
@@ -613,6 +622,14 @@ export function LibraryBrowser({
|
|||||||
</DialogFrame>
|
</DialogFrame>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{importPath && (
|
||||||
|
<ImportLocationDialog
|
||||||
|
path={importPath}
|
||||||
|
onImport={(location) => runImport(importPath, location)}
|
||||||
|
onCancel={() => setImportPath(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{importProgress && showImportProgress && (
|
{importProgress && showImportProgress && (
|
||||||
<ProgressDialog
|
<ProgressDialog
|
||||||
title="Importing cards…"
|
title="Importing cards…"
|
||||||
@@ -795,3 +812,60 @@ export function LibraryBrowser({
|
|||||||
</div>
|
</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