Skip to content
Open
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
6b970fa
Import .bloomSource files into a collection, with duplicate-by-id han…
hatton Jul 1, 2026
10ea1e0
Address Greptile style nits: using-scope the zip, tidy useState (BL-1…
hatton Jul 2, 2026
3e20160
Address preflight review items for .bloomSource import (BL-16502)
hatton Jul 2, 2026
3341445
Add an icon to the "Import .bloomSource File(s)" collection menu item…
hatton Jul 2, 2026
1951248
Address Greptile review: prevent import data loss, fix doc comment (B…
hatton Jul 2, 2026
033d12b
Reload the collection once per import batch, not once per file (BL-16…
hatton Jul 2, 2026
cf22145
Don't fail a successful import when recycling the replaced book fails…
hatton Jul 2, 2026
9e9a96d
Prevent same-id books in one import batch from colliding (BL-16502)
hatton Jul 2, 2026
2c1d8b4
Refine .bloomSource import dialogs and use Bloom radio component (BL-…
hatton Jul 2, 2026
9548eb5
Add an explanatory line under the "Import as derivatives" choice (BL-…
hatton Jul 2, 2026
5abcdc0
Tweak messages
hatton Jul 2, 2026
f63a1b6
Disable "Replace" for un-checked-out Team Collection books on import …
hatton Jul 3, 2026
8273d07
Overwrite in place when replacing a book on .bloomSource import (BL-1…
hatton Jul 7, 2026
be36527
Move .bloomSource import strings to medium priority (BL-16502)
hatton Jul 7, 2026
cd6dc01
Extract .bloomSource import to its own file and make its API stateles…
hatton Jul 7, 2026
5e0027a
Fix XLF note order: ID note before context note (BL-16502)
hatton Jul 7, 2026
b8968eb
Merge remote-tracking branch 'origin/Version6.4' into BL-16502-Import…
hatton Jul 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions DistFiles/localization/en/Bloom.xlf
Original file line number Diff line number Diff line change
Expand Up @@ -792,6 +792,71 @@
<note>Shell Books are books that are meant to be translated.</note>
<note>ID: CollectionTab.MakeBloomPackOfShellBooks</note>
</trans-unit>
<trans-unit id="CollectionTab.ImportBloomSource" translate="no">
<source xml:lang="en">Import .bloomSource File(s)</source>
<note>Menu command (and dialog title) that imports one or more books from .bloomSource files into the current collection. Keep ".bloomSource" unchanged; it is a file extension.</note>
<note>ID: CollectionTab.ImportBloomSource</note>
</trans-unit>
<trans-unit id="CollectionTab.ImportBloomSource.ChoiceTitle" translate="no">
<source xml:lang="en">Import books</source>
<note>Title of the dialogs shown while importing .bloomSource file(s): the one that asks whether to import for editing or as derivatives, and the one that asks how to handle books already in the collection.</note>
<note>ID: CollectionTab.ImportBloomSource.ChoiceTitle</note>
</trans-unit>
<trans-unit id="CollectionTab.ImportBloomSource.EditChoice" translate="no">
<source xml:lang="en">Import for editing</source>
<note>Radio-button choice in the import dialog: import the book(s) to edit them directly.</note>
<note>ID: CollectionTab.ImportBloomSource.EditChoice</note>
</trans-unit>
<trans-unit id="CollectionTab.ImportBloomSource.EditChoiceDescription" translate="no">
<source xml:lang="en">Preserve everything.</source>
<note>Explanation shown under the "Import for editing" radio-button choice in the import dialog.</note>
<note>ID: CollectionTab.ImportBloomSource.EditChoiceDescription</note>
</trans-unit>
<trans-unit id="CollectionTab.ImportBloomSource.DerivativeChoice" translate="no">
<source xml:lang="en">Import as derivatives — new books based on the originals</source>
<note>Radio-button choice in the import dialog: use the imported book(s) as the basis for new derivative books (e.g. translations).</note>
<note>ID: CollectionTab.ImportBloomSource.DerivativeChoice</note>
</trans-unit>
<trans-unit id="CollectionTab.ImportBloomSource.DerivativeChoiceDescription" translate="no">
<source xml:lang="en">Use a new book ID &amp; make room for new copyright and credits while preserving those of the original.</source>
<note>Explanation shown under the "Import as derivatives" radio-button choice in the import dialog.</note>
<note>ID: CollectionTab.ImportBloomSource.DerivativeChoiceDescription</note>
</trans-unit>
<trans-unit id="CollectionTab.ImportBloomSource.DuplicatePrompt" translate="no">
<source xml:lang="en">Some of these books are already in this collection.</source>
<note>Shown once when importing .bloomSource file(s), if any of the books is already in the collection.</note>
<note>ID: CollectionTab.ImportBloomSource.DuplicatePrompt</note>
</trans-unit>
<trans-unit id="CollectionTab.ImportBloomSource.Replace" translate="no">
<source xml:lang="en">Replace the existing books</source>
<note>Radio-button choice in the import duplicate prompt: replace the existing book(s) with the imported one(s).</note>
<note>ID: CollectionTab.ImportBloomSource.Replace</note>
</trans-unit>
<trans-unit id="CollectionTab.ImportBloomSource.ReplaceDescription" translate="no">
<source xml:lang="en">The current copies will be overwritten.</source>
<note>Secondary line under the "Replace the existing books" choice in the import duplicate prompt.</note>
<note>ID: CollectionTab.ImportBloomSource.ReplaceDescription</note>
</trans-unit>
<trans-unit id="CollectionTab.ImportBloomSource.ReplaceNeedsCheckout" translate="no">
<source xml:lang="en">All books must be checked out</source>
<note>Secondary line under the "Replace the existing books" choice in the import duplicate prompt, shown (and the choice disabled) when the collection is a Team Collection and one or more of the books being replaced is not checked out.</note>
<note>ID: CollectionTab.ImportBloomSource.ReplaceNeedsCheckout</note>
</trans-unit>
<trans-unit id="CollectionTab.ImportBloomSource.AddCopy" translate="no">
<source xml:lang="en">Add them as new copies</source>
<note>Radio-button choice in the import duplicate prompt: keep the existing book(s) and add the imported one(s) as new copies.</note>
<note>ID: CollectionTab.ImportBloomSource.AddCopy</note>
</trans-unit>
<trans-unit id="CollectionTab.ImportBloomSource.AddCopyDescription" translate="no">
<source xml:lang="en">The existing books stay as they are.</source>
<note>Secondary line under the "Add them as new copies" choice in the import duplicate prompt.</note>
<note>ID: CollectionTab.ImportBloomSource.AddCopyDescription</note>
</trans-unit>
<trans-unit id="CollectionTab.ImportBloomSource.Importing" translate="no">
<source xml:lang="en">Importing Books</source>
<note>Title of the progress dialog shown while .bloomSource file(s) are being imported.</note>
<note>ID: CollectionTab.ImportBloomSource.Importing</note>
</trans-unit>
<trans-unit id="CollectionTab.MakeBookUsingThisTemplate">
<source xml:lang="en">Make a book using this source</source>
<note>ID: CollectionTab.MakeBookUsingThisTemplate</note>
Expand Down
149 changes: 149 additions & 0 deletions src/BloomBrowserUI/collectionsTab/CollectionsTabPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { EmbeddedProgressDialog } from "../react_components/Progress/ProgressDia
import { useSubscribeToWebSocketForObject } from "../utils/WebSocketManager";
import CloseIcon from "@mui/icons-material/Close";
import FolderOpenOutlinedIcon from "@mui/icons-material/FolderOpenOutlined";
import { ImportIcon } from "../react_components/icons/ImportIcon";
import { kBloomBlue } from "../bloomMaterialUITheme";
import { BloomTooltip } from "../react_components/BloomToolTip";
import { Link } from "../react_components/link";
Expand All @@ -37,6 +38,7 @@ import {
showMakeReaderTemplateBloomPackDialog,
} from "../react_components/makeReaderTemplateBloomPackDialog";
import { AboutDialogLauncher } from "../react_components/aboutDialog";
import { RadioChoiceDialog } from "./RadioChoiceDialog";

const kResizerSize = 10;

Expand Down Expand Up @@ -197,6 +199,101 @@ export const CollectionsTabPane: React.FunctionComponent = () => {
| undefined
>();

// The .bloomSource import decision. The file(s) are chosen first (via C#); then we check (via
// C#) whether any chosen book is already in the collection and, based on that, show the user
// exactly one dialog for the whole batch. If none are present, Dialog 1 asks whether to edit
// the book(s) or make derivatives. If any are present, Dialog 2 asks whether to replace the
// existing book(s) or add the imported ones as new copies. The user never sees both.
const [showImportSourceChoiceDialog, setShowImportSourceChoiceDialog] =
useState(false);
const [showImportDuplicateDialog, setShowImportDuplicateDialog] =
useState(false);
// Whether the "Replace" duplicate choice is allowed for this batch. In a Team Collection a book
// can only be replaced if it is checked out here; when any duplicate isn't, Replace is disabled.
const [importCanReplace, setImportCanReplace] = useState(true);

const importChoiceTitle = useL10n(
"Import books",
"CollectionTab.ImportBloomSource.ChoiceTitle",
);
const importEditChoiceLabel = useL10n(
"Import for editing",
"CollectionTab.ImportBloomSource.EditChoice",
);
const importEditChoiceDescription = useL10n(
"Preserve everything.",
"CollectionTab.ImportBloomSource.EditChoiceDescription",
);
const importDerivativeChoiceLabel = useL10n(
"Import as derivatives — new books based on the originals",
"CollectionTab.ImportBloomSource.DerivativeChoice",
);
const importDerivativeChoiceDescription = useL10n(
"Use a new book ID & make room for new copyright and credits while preserving those of the original.",
"CollectionTab.ImportBloomSource.DerivativeChoiceDescription",
);
const importDuplicateMessage = useL10n(
"Some of these books are already in this collection.",
"CollectionTab.ImportBloomSource.DuplicatePrompt",
);
const importReplaceLabel = useL10n(
"Replace the existing books",
"CollectionTab.ImportBloomSource.Replace",
);
const importReplaceDescription = useL10n(
"The current copies will be overwritten.",
"CollectionTab.ImportBloomSource.ReplaceDescription",
);
const importReplaceNeedsCheckoutDescription = useL10n(
"All books must be checked out",
"CollectionTab.ImportBloomSource.ReplaceNeedsCheckout",
);
Comment thread
hatton marked this conversation as resolved.
const importAddCopyLabel = useL10n(
"Add them as new copies",
"CollectionTab.ImportBloomSource.AddCopy",
);
const importAddCopyDescription = useL10n(
"The existing books stay as they are.",
"CollectionTab.ImportBloomSource.AddCopyDescription",
);

// Decide which single dialog to show after the file(s) have been chosen: Dialog 2 (replace vs.
// add-copy) if any chosen book is already in the collection, otherwise Dialog 1 (edit vs.
// derivative).
//
// Note that when a chosen book is already in the collection we deliberately do NOT offer the
// edit-vs-derivative choice: the duplicate dialog always imports in edit mode, so you can't
// make a derivative of a book that's already in your collection. We couldn't think of a
// plausible scenario where you have a book and a true derivative of it in the same collection,
// so this path isn't worth the extra complexity. Easy to reverse if that turns out to be wrong.
const chooseImportDialog = () => {
post("collections/bloomSourceImportDuplicateInfo", (r) => {
if (r.data.anyDuplicates) {
setImportCanReplace(r.data.canReplace);
setShowImportDuplicateDialog(true);
} else setShowImportSourceChoiceDialog(true);
});
};

// Dialog 1 (shown only when no chosen book is already in the collection): the user chose to
// edit the book(s) as-is or make derivatives (or cancelled). Neither path has a duplicate to
// resolve, so import immediately.
const handleImportSourceChoice = (choice?: string) => {
setShowImportSourceChoiceDialog(false);
if (!choice) return;
const mode = choice === "derivative" ? "derivative" : "edit";
post(`collections/importBloomSource?mode=${mode}`);
};

// Dialog 2 (shown only when at least one chosen book is already in the collection): the user
// chose to replace the existing book(s) or add the imported ones as new copies. That single
// choice applies to every duplicate in the batch.
const handleImportDuplicateChoice = (choice?: string) => {
setShowImportDuplicateDialog(false);
if (!choice) return;
post(`collections/importBloomSource?mode=edit&onDuplicate=${choice}`);
};

const setAdjustedContextMenuPoint = (x: number, y: number) => {
setContextMousePoint({
mouseX: x - 2,
Expand Down Expand Up @@ -277,6 +374,20 @@ export const CollectionsTabPane: React.FunctionComponent = () => {
command: "workspace/openOrCreateCollection",
addEllipsis: true,
},
{
label: "Import .bloomSource File(s)",
l10nId: "CollectionTab.ImportBloomSource",
icon: <ImportIcon />,
onClick: () => {
handleClose();
// Choose the file(s) first; then show the one dialog that fits, based on whether
// any chosen book is already in the collection.
post("collections/chooseBloomSourceFilesToImport", (r) => {
if (r.data === true) chooseImportDialog();
});
},
addEllipsis: true,
},
{
label: "Make Bloom Pack of Shell Books",
l10nId: "CollectionTab.MakeBloomPackOfShellBooks",
Expand Down Expand Up @@ -587,6 +698,44 @@ export const CollectionsTabPane: React.FunctionComponent = () => {
<CollectionSettingsDialog />
<EmbeddedProgressDialog id="collectionTab" />
<MakeReaderTemplateBloomPackDialog />
<RadioChoiceDialog
open={showImportSourceChoiceDialog}
title={importChoiceTitle}
options={[
{
value: "edit",
label: importEditChoiceLabel,
description: importEditChoiceDescription,
},
{
value: "derivative",
label: importDerivativeChoiceLabel,
description: importDerivativeChoiceDescription,
},
]}
onClose={handleImportSourceChoice}
/>
<RadioChoiceDialog
open={showImportDuplicateDialog}
title={importChoiceTitle}
message={importDuplicateMessage}
options={[
{
value: "replace",
label: importReplaceLabel,
description: importCanReplace
? importReplaceDescription
: importReplaceNeedsCheckoutDescription,
disabled: !importCanReplace,
},
{
value: "addCopy",
label: importAddCopyLabel,
description: importAddCopyDescription,
},
]}
onClose={handleImportDuplicateChoice}
/>
</div>
);
};
Expand Down
94 changes: 94 additions & 0 deletions src/BloomBrowserUI/collectionsTab/RadioChoiceDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { css } from "@emotion/react";
import * as React from "react";
import { useState, useEffect } from "react";
import { RadioGroup } from "@mui/material";
import { MuiRadio } from "../react_components/muiRadio";
import {
BloomDialog,
DialogTitle,
DialogMiddle,
DialogBottomButtons,
} from "../react_components/BloomDialog/BloomDialog";
import {
DialogOkButton,
DialogCancelButton,
} from "../react_components/BloomDialog/commonDialogComponents";

export interface IRadioChoice {
value: string;
label: string; // already localized
description?: string; // optional already-localized secondary line shown under the label
disabled?: boolean; // when true the choice can't be selected (e.g. not currently allowed)
}

// A small, reusable dialog that offers a set of mutually-exclusive radio choices with OK and
// Cancel. OK is disabled until the user selects one; Cancel is always available. It is rendered
// inline as part of its parent screen; the parent controls `open` and receives the chosen value
// (or undefined if the user cancelled) via `onClose`. All strings are passed in already localized.
export const RadioChoiceDialog: React.FunctionComponent<{
open: boolean;
title: string;
message?: string;
options: IRadioChoice[];
onClose: (value?: string) => void;
}> = (props) => {
const [choice, setChoice] = useState<string>();

// Start with nothing selected each time the dialog opens, so OK is disabled until the user picks.
useEffect(() => {
Comment thread
hatton marked this conversation as resolved.
if (props.open) setChoice(undefined);
}, [props.open]);

const cancel = () => props.onClose(undefined);

return (
<BloomDialog
open={props.open}
onClose={cancel}
onCancel={cancel}
dialogFrameProvidedExternally={false}
>
<DialogTitle title={props.title} />
<DialogMiddle
css={css`
width: 400px;
`}
>
{props.message && (
<p
css={css`
margin-top: 0;
`}
>
{props.message}
</p>
)}
<RadioGroup
value={choice ?? ""}
onChange={(e) => setChoice(e.target.value)}
>
{props.options.map((o) => (
<MuiRadio
key={o.value}
value={o.value}
label={o.label}
description={o.description}
disabled={o.disabled}
// The labels arrive already localized from the caller.
alreadyLocalized={true}
l10nKey=""
/>
))}
</RadioGroup>
</DialogMiddle>
<DialogBottomButtons>
<DialogOkButton
default={true}
enabled={choice !== undefined}
onClick={() => props.onClose(choice)}
/>
<DialogCancelButton />
</DialogBottomButtons>
</BloomDialog>
);
};
15 changes: 15 additions & 0 deletions src/BloomBrowserUI/react_components/icons/ImportIcon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import SvgIcon, { SvgIconProps } from "@mui/material/SvgIcon";

// An "import into a document" icon (arrow pointing into a page), used for the
// "Import .bloomSource File(s)" collection menu item. Uses currentColor so it follows
// the menu text color.
export const ImportIcon = (props: SvgIconProps) => (
<SvgIcon viewBox="0 0 18 16" {...props}>
<path
d="M12 8L8 4V7H0V9H8V12M18 14V2C18 1.46957 17.7893 0.960859 17.4142 0.585786C17.0391 0.210714 16.5304 0 16 0H4C3.46957 0 2.96086 0.210714 2.58579 0.585786C2.21071 0.960859 2 1.46957 2 2V5H4V2H16V14H4V11H2V14C2 14.5304 2.21071 15.0391 2.58579 15.4142C2.96086 15.7893 3.46957 16 4 16H16C16.5304 16 17.0391 15.7893 17.4142 15.4142C17.7893 15.0391 18 14.5304 18 14Z"
fill="currentColor"
/>
</SvgIcon>
);

export default ImportIcon;
Loading