From 46fd9e35760b6dccc9a54f25d11ebbda6ae4297a Mon Sep 17 00:00:00 2001 From: Douglas Carmichael Date: Sun, 9 Aug 2026 18:22:42 -0400 Subject: [PATCH 1/3] Contents dialog: export the list of the found presets as a CSV or JSON file The new 'Export List...' button writes the presets which are currently listed - not the presets themselves - to a CSV or JSON file: name, category, number of zones, key range as note names and as MIDI note numbers, folder, file, containers, index inside of the file and whether the preset is ticked. This gives an inventory of a disk image, a bank or a preset folder which can be read in a spreadsheet or by a script. The search filter applies to the written list as well and the file format follows the ending of the picked file; a file without an ending gets the one of the format which is selected in the file dialog. --- documentation/CHANGELOG.md | 1 + documentation/README.md | 2 + .../convertwithmoss/core/ContentsEntry.java | 24 +- .../convertwithmoss/ui/ContentsDialog.java | 124 ++++++++- .../convertwithmoss/ui/ContentsExporter.java | 253 ++++++++++++++++++ .../convertwithmoss/ui/MainFrame.java | 2 +- src/main/resources/Strings.properties | 8 + 7 files changed, 400 insertions(+), 14 deletions(-) create mode 100644 src/main/java/de/mossgrabers/convertwithmoss/ui/ContentsExporter.java diff --git a/documentation/CHANGELOG.md b/documentation/CHANGELOG.md index ce069776..0b0fe0e2 100644 --- a/documentation/CHANGELOG.md +++ b/documentation/CHANGELOG.md @@ -9,6 +9,7 @@ * New: Added support for the Audiomodern Soundbox format (reading and writing of sound packs (SBPACK): every preset of a pack becomes one multi-sample with the key/velocity ranges, root notes, sample start/end, loops with cross-fade (also ping-pong), reverse flag, volume, panning and tuning of its sounds and the volume, panning, tuning and amplitude envelope of the layers folded in; round robin layers are read as round robin groups and round robin groups are written as round robin layers; a filter in one of the effect slots becomes the filter of the zones and the voice mode and glide become the polyphony and portamento; writing creates one pack with one preset per source and stores identical samples only once - import the written pack into the plug-in, verified with Soundbox 1.2.1). * User Interface * New: The folder/file history does now remember the selected source format for the folder/file and restores it on selection. + * New: Contents dialog: The new 'Export List...' button writes the listed presets - not the presets themselves - as a CSV or JSON file with the name, category, number of zones, key range (as note names and MIDI note numbers), folder, file, containers, index inside of the file and the ticked state of each of them. This gives an inventory of a disk image, a bank or a preset folder which can be used in a spreadsheet or a script. A search filter applies to the written list as well. * New: Contents dialog: The filter field can now be cleared with 'X' and has the focus when the dialog is opened. * Fixed: Contents dialog: Using 'Select All' on filtered content did still select all presets not only the filtered ones. * Fixed: Tabbing in dialogs did not work. diff --git a/documentation/README.md b/documentation/README.md index bdf92012..a78f71d2 100644 --- a/documentation/README.md +++ b/documentation/README.md @@ -110,6 +110,8 @@ Every source format has a *Contents...* button. It reads the source without writ This works for a format where one file simply is one preset as well, e.g. a folder of Elektron Tonverk or Synthstrom Deluge presets. The tree then lists the presets themselves - under the folder they are in and with the name which is stored inside of the file, which is not necessarily the name of that file. The name of the file is added to a preset which is named differently. Only the files of the ticked presets are read again by the conversion, so narrowing a large folder down also makes the conversion much quicker. +*Export List...* writes the list itself - not the presets - to a CSV or JSON file, which is handy to keep an inventory of a disk image, a bank or a preset folder or to work with it in a spreadsheet or a script. Every listed preset becomes one line (CSV) or one object (JSON) with its name, category, number of zones, key range as note names and as MIDI note numbers, its folder, file and containers, its index inside of the file and whether it is ticked. The file format follows the ending you pick in the file dialog and a search filter applies to the export as well, so a filtered list writes only the presets which are shown. + Highlighting a preset and pressing *Play* - or double-clicking it - plays one note of it. The note is rendered from the preset as it was read, with its amplitude and filter envelopes, its filter and its LFOs applied, so you hear what the conversion produces and can pick the presets by ear instead of by name. The preset is read from its file again for this, which takes about a tenth of a second even inside a large disk image. Pressing *Play* again stops the note, as does closing the dialog. ## Processing diff --git a/src/main/java/de/mossgrabers/convertwithmoss/core/ContentsEntry.java b/src/main/java/de/mossgrabers/convertwithmoss/core/ContentsEntry.java index 690ad829..3789da31 100644 --- a/src/main/java/de/mossgrabers/convertwithmoss/core/ContentsEntry.java +++ b/src/main/java/de/mossgrabers/convertwithmoss/core/ContentsEntry.java @@ -169,6 +169,28 @@ public int getNumberOfZones () } + /** + * Get the lowest key which is covered by any of the zones of the source. + * + * @return The MIDI note number, -1 if the source has no zones at all + */ + public int getLowestKey () + { + return this.lowestKey; + } + + + /** + * Get the highest key which is covered by any of the zones of the source. + * + * @return The MIDI note number, -1 if the source has no zones at all + */ + public int getHighestKey () + { + return this.highestKey; + } + + /** * Get the category of the source. * @@ -203,7 +225,7 @@ public String getInfo () * @param note The MIDI note number * @return The formatted note */ - private static String formatNote (final int note) + public static String formatNote (final int note) { return NOTE_NAMES[note % 12] + (note / 12 - 2); } diff --git a/src/main/java/de/mossgrabers/convertwithmoss/ui/ContentsDialog.java b/src/main/java/de/mossgrabers/convertwithmoss/ui/ContentsDialog.java index 34b622a0..bfafb7e4 100644 --- a/src/main/java/de/mossgrabers/convertwithmoss/ui/ContentsDialog.java +++ b/src/main/java/de/mossgrabers/convertwithmoss/ui/ContentsDialog.java @@ -10,12 +10,15 @@ import java.util.HashMap; import java.util.HashSet; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Set; import de.mossgrabers.convertwithmoss.core.ContentsEntry; import de.mossgrabers.convertwithmoss.core.IMultisampleSource; +import de.mossgrabers.convertwithmoss.ui.ContentsExporter.Format; import de.mossgrabers.tools.FileUtils; +import de.mossgrabers.tools.ui.BasicConfig; import de.mossgrabers.tools.ui.ControlFunctions; import de.mossgrabers.tools.ui.Functions; import de.mossgrabers.tools.ui.PseudoModalDialog; @@ -36,6 +39,8 @@ import javafx.scene.layout.Pane; import javafx.scene.layout.Priority; import javafx.scene.layout.StackPane; +import javafx.stage.FileChooser; +import javafx.stage.FileChooser.ExtensionFilter; import javafx.stage.Stage; import javafx.util.StringConverter; @@ -52,8 +57,9 @@ public class ContentsDialog extends PseudoModalDialog private TreeView treeView; private TextField searchField; private Label selectionLabel; - private Label auditionLabel; + private Label statusLabel; private Button auditionButton; + private Button exportButton; private List entries = new ArrayList<> (); private final Set selectedEntries = new HashSet<> (); private final Map entriesPerFile = new HashMap<> (); @@ -63,15 +69,20 @@ public class ContentsDialog extends PseudoModalDialog private ISourceReader sourceReader; private boolean isReading = false; + private final BasicConfig config; + /** * Constructor. * * @param owner The owner of the dialog + * @param config The configuration, which stores the folder of the last export */ - protected ContentsDialog (final Stage owner) + protected ContentsDialog (final Stage owner, final BasicConfig config) { super (owner, "@IDS_CONTENTS_DIALOG"); + + this.config = config; } @@ -109,19 +120,24 @@ protected Pane init () }); this.selectionLabel = new Label (); - this.auditionLabel = new Label (); - this.auditionLabel.setMaxWidth (Double.MAX_VALUE); - this.auditionLabel.setAlignment (Pos.CENTER_RIGHT); + this.statusLabel = new Label (); + this.statusLabel.setMaxWidth (Double.MAX_VALUE); + this.statusLabel.setAlignment (Pos.CENTER_RIGHT); + + this.exportButton = new Button (Functions.getText ("@IDS_CONTENTS_EXPORT")); + this.exportButton.setTooltip (new Tooltip (Functions.getText ("@IDS_CONTENTS_EXPORT_TOOLTIP"))); + this.exportButton.setOnAction (_ -> this.exportContents ()); + this.exportButton.setDisable (true); this.auditionButton = new Button (Functions.getText ("@IDS_CONTENTS_AUDITION")); this.auditionButton.setTooltip (new Tooltip (Functions.getText ("@IDS_CONTENTS_AUDITION_TOOLTIP"))); this.auditionButton.setOnAction (_ -> this.toggleAudition ()); this.auditionButton.setDisable (true); - final HBox bottomRow = new HBox (this.selectionLabel, this.auditionLabel, this.auditionButton); + final HBox bottomRow = new HBox (this.selectionLabel, this.statusLabel, this.exportButton, this.auditionButton); bottomRow.getStyleClass ().addAll ("contentsToolbar", "contentsDialogRow"); bottomRow.setAlignment (Pos.CENTER_LEFT); - HBox.setHgrow (this.auditionLabel, Priority.ALWAYS); + HBox.setHgrow (this.statusLabel, Priority.ALWAYS); pane.setTop (topRow); pane.setCenter (this.treeView); @@ -136,6 +152,7 @@ protected Pane init () this.traversalManager.add (selectAllButton); this.traversalManager.add (selectNoneButton); this.traversalManager.add (this.treeView); + this.traversalManager.add (this.exportButton); this.traversalManager.add (this.auditionButton); this.traversalManager.add (this.getOkButton ()); this.traversalManager.add (this.getCancelButton ()); @@ -165,7 +182,7 @@ public void setEntries (final List entries, final ISourceReader s this.entriesPerFile.merge (entry.getSourceFile (), Integer.valueOf (1), Integer::sum); this.searchField.setText (""); - this.auditionLabel.setText (""); + this.statusLabel.setText (""); this.fillTree (); this.updateAuditionButton (); } @@ -189,7 +206,7 @@ private void toggleAudition () if (this.auditionPlayer.isPlaying ()) { this.auditionPlayer.stop (); - this.auditionLabel.setText (""); + this.statusLabel.setText (""); this.updateAuditionButton (); return; } @@ -209,7 +226,7 @@ private void startAudition () this.auditionPlayer.stop (); this.isReading = true; - this.auditionLabel.setText (Functions.getMessage ("IDS_CONTENTS_AUDITION_READING", entry.getName ())); + this.statusLabel.setText (Functions.getMessage ("IDS_CONTENTS_AUDITION_READING", entry.getName ())); this.updateAuditionButton (); final Thread readThread = new Thread (() -> { @@ -232,7 +249,7 @@ else if (!this.auditionPlayer.play (source, () -> Platform.runLater (this::endAu Platform.runLater (() -> { this.isReading = false; - this.auditionLabel.setText (labelText); + this.statusLabel.setText (labelText); this.updateAuditionButton (); }); @@ -251,7 +268,7 @@ private void endAudition () { if (this.isReading) return; - this.auditionLabel.setText (""); + this.statusLabel.setText (""); this.updateAuditionButton (); } @@ -322,6 +339,86 @@ private void setAllSelected (final boolean isSelected) } + /** + * Write the list of the sources which are currently displayed - therefore the search filter + * applies - to a CSV or JSON file, which is useful to keep an inventory of a bank, a disk image + * or a preset folder or to process it with another application. Only the list is written, the + * presets themselves are not converted. Whether a source is ticked is one of the written + * fields, so that a selection is not lost by the export. + */ + private void exportContents () + { + final List displayedEntries = new ArrayList<> (); + collectEntries (this.treeView.getRoot (), displayedEntries); + if (displayedEntries.isEmpty ()) + return; + + final FileChooser chooser = new FileChooser (); + chooser.setTitle (Functions.getText ("@IDS_CONTENTS_EXPORT_HEADER")); + final ExtensionFilter csvFilter = new ExtensionFilter (Functions.getText ("@IDS_CONTENTS_EXPORT_CSV"), "*.csv"); + final ExtensionFilter jsonFilter = new ExtensionFilter (Functions.getText ("@IDS_CONTENTS_EXPORT_JSON"), "*.json"); + chooser.getExtensionFilters ().addAll (csvFilter, jsonFilter); + final String activePath = this.config.getActivePath (); + if (activePath != null) + { + final File activeFolder = new File (activePath); + if (activeFolder.isDirectory ()) + chooser.setInitialDirectory (activeFolder); + } + chooser.setInitialFileName (Functions.getText ("@IDS_CONTENTS_EXPORT_FILE_NAME") + ".csv"); + + final File selectedFile = chooser.showSaveDialog (this.owner); + if (selectedFile == null) + return; + final File parentFolder = selectedFile.getParentFile (); + if (parentFolder != null) + this.config.setActivePath (parentFolder); + + // The file ending decides the format; a file which was given none gets the ending of the + // format which is picked in the dialog, since not every platform appends it + final String fileName = selectedFile.getName ().toLowerCase (Locale.US); + File file = selectedFile; + final Format format; + if (fileName.endsWith (".json")) + format = Format.JSON; + else if (fileName.endsWith (".csv")) + format = Format.CSV; + else + { + format = chooser.getSelectedExtensionFilter () == jsonFilter ? Format.JSON : Format.CSV; + file = new File (parentFolder, selectedFile.getName () + (format == Format.JSON ? ".json" : ".csv")); + } + + try + { + ContentsExporter.export (file, format, displayedEntries, this.selectedEntries); + this.statusLabel.setText (Functions.getMessage ("IDS_CONTENTS_EXPORT_DONE", Integer.toString (displayedEntries.size ()), file.getName ())); + } + catch (final IOException ex) + { + this.statusLabel.setText (""); + Functions.error ("@IDS_CONTENTS_EXPORT_FAILED", ex); + } + } + + + /** + * Collect all sources of a branch of the tree, in the order in which they are displayed. + * + * @param item The item to start at, its own source is collected as well + * @param entries Where to collect the sources + */ + private static void collectEntries (final TreeItem item, final List entries) + { + if (item == null) + return; + if (item.getValue () instanceof final ContentsEntry entry) + entries.add (entry); + for (final TreeItem child: item.getChildren ()) + collectEntries (child, entries); + } + + /** * Create the tree from the found sources. The folders of the source folder, each source file * which holds more than the source itself and each of its containers become a folder, the @@ -378,6 +475,9 @@ private void fillTree () } this.treeView.setRoot (root); + // A folder is only created for a source which is displayed, therefore an empty root means + // that there is nothing to export + this.exportButton.setDisable (root.getChildren ().isEmpty ()); this.updateSelectionLabel (); } diff --git a/src/main/java/de/mossgrabers/convertwithmoss/ui/ContentsExporter.java b/src/main/java/de/mossgrabers/convertwithmoss/ui/ContentsExporter.java new file mode 100644 index 00000000..9a38f7cc --- /dev/null +++ b/src/main/java/de/mossgrabers/convertwithmoss/ui/ContentsExporter.java @@ -0,0 +1,253 @@ +// Written by Jürgen Moßgraber - mossgrabers.de +// (c) 2019-2026 +// Licensed under LGPLv3 - http://www.gnu.org/licenses/lgpl-3.0.txt + +package de.mossgrabers.convertwithmoss.ui; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.List; +import java.util.Set; + +import de.mossgrabers.convertwithmoss.core.ContentsEntry; + + +/** + * Writes the list of the sources which are shown in the contents dialog to a text file, so that the + * contents of a bank, a disk image or a whole preset folder can be kept as an inventory or be + * processed by another application, e.g. a spreadsheet or a script. Only the list is written, the + * presets themselves are not converted. + * + * The field names are intentionally not translated, since they are read by other applications and + * must therefore not depend on the language of the user interface. + * + * @author Jürgen Moßgraber + */ +public final class ContentsExporter +{ + /** The file formats into which the contents can be written. */ + public enum Format + { + /** Comma separated values with one line per source. */ + CSV, + /** An array with one object per source. */ + JSON + } + + + private static final String [] COLUMNS = + { + "Name", + "Category", + "Zones", + "Key Low", + "Key High", + "MIDI Low", + "MIDI High", + "Folder", + "File", + "Container", + "Index", + "Selected", + "Path" + }; + + + /** + * Private due to helper class. + */ + private ContentsExporter () + { + // Intentionally empty + } + + + /** + * Write the given sources to a file. + * + * @param file The file to write to, it is overwritten if it already exists + * @param format The format to write + * @param entries The sources to write, in the order in which they are displayed + * @param selectedEntries The sources which are ticked, they are marked as selected + * @throws IOException Could not write the file + */ + public static void export (final File file, final Format format, final List entries, final Set selectedEntries) throws IOException + { + final String content = format == Format.JSON ? createJSON (entries, selectedEntries) : createCSV (entries, selectedEntries); + Files.writeString (file.toPath (), content, StandardCharsets.UTF_8); + } + + + /** + * Format the sources as comma separated values with a header line. + * + * @param entries The sources to write + * @param selectedEntries The sources which are ticked + * @return The formatted contents + */ + private static String createCSV (final List entries, final Set selectedEntries) + { + final StringBuilder sb = new StringBuilder (); + for (int i = 0; i < COLUMNS.length; i++) + { + if (i > 0) + sb.append (','); + sb.append (COLUMNS[i]); + } + sb.append ('\n'); + + for (final ContentsEntry entry: entries) + { + final int lowestKey = entry.getLowestKey (); + final boolean hasKeyRange = lowestKey >= 0; + final File sourceFile = entry.getSourceFile (); + + appendCSV (sb, entry.getName (), true); + appendCSV (sb, entry.getCategory (), false); + appendCSV (sb, Integer.toString (entry.getNumberOfZones ()), false); + appendCSV (sb, hasKeyRange ? ContentsEntry.formatNote (lowestKey) : "", false); + appendCSV (sb, hasKeyRange ? ContentsEntry.formatNote (entry.getHighestKey ()) : "", false); + appendCSV (sb, hasKeyRange ? Integer.toString (lowestKey) : "", false); + appendCSV (sb, hasKeyRange ? Integer.toString (entry.getHighestKey ()) : "", false); + appendCSV (sb, String.join ("/", entry.getFolderPath ()), false); + appendCSV (sb, sourceFile == null ? "" : sourceFile.getName (), false); + appendCSV (sb, String.join ("/", entry.getContainerPath ()), false); + appendCSV (sb, Integer.toString (entry.getIndexInFile ()), false); + appendCSV (sb, Boolean.toString (selectedEntries.contains (entry)), false); + appendCSV (sb, sourceFile == null ? "" : sourceFile.getAbsolutePath (), false); + sb.append ('\n'); + } + + return sb.toString (); + } + + + /** + * Append one field to a line, quoted if necessary. + * + * @param sb Where to append the field + * @param value The value of the field + * @param isFirst True if it is the first field of the line, which needs no separator + */ + private static void appendCSV (final StringBuilder sb, final String value, final boolean isFirst) + { + if (!isFirst) + sb.append (','); + // Only quote a field which needs it, which keeps the file readable + if (value.indexOf (',') < 0 && value.indexOf ('"') < 0 && value.indexOf ('\n') < 0 && value.indexOf ('\r') < 0) + { + sb.append (value); + return; + } + sb.append ('"').append (value.replace ("\"", "\"\"")).append ('"'); + } + + + /** + * Format the sources as an array of JSON objects. A source which has no zones at all has no key + * range either, which is written as null. + * + * @param entries The sources to write + * @param selectedEntries The sources which are ticked + * @return The formatted contents + */ + private static String createJSON (final List entries, final Set selectedEntries) + { + final StringBuilder sb = new StringBuilder ("[\n"); + + for (int i = 0; i < entries.size (); i++) + { + final ContentsEntry entry = entries.get (i); + final int lowestKey = entry.getLowestKey (); + final int highestKey = entry.getHighestKey (); + final boolean hasKeyRange = lowestKey >= 0; + final File sourceFile = entry.getSourceFile (); + + sb.append (" {\n"); + appendJSON (sb, "name", quoteJSON (entry.getName ())); + appendJSON (sb, "category", quoteJSON (entry.getCategory ())); + appendJSON (sb, "zones", Integer.toString (entry.getNumberOfZones ())); + appendJSON (sb, "keyLow", hasKeyRange ? quoteJSON (ContentsEntry.formatNote (lowestKey)) : "null"); + appendJSON (sb, "keyHigh", hasKeyRange ? quoteJSON (ContentsEntry.formatNote (highestKey)) : "null"); + appendJSON (sb, "midiLow", hasKeyRange ? Integer.toString (lowestKey) : "null"); + appendJSON (sb, "midiHigh", hasKeyRange ? Integer.toString (highestKey) : "null"); + appendJSON (sb, "folder", createJSONArray (entry.getFolderPath ())); + appendJSON (sb, "file", sourceFile == null ? "null" : quoteJSON (sourceFile.getName ())); + appendJSON (sb, "container", createJSONArray (entry.getContainerPath ())); + appendJSON (sb, "index", Integer.toString (entry.getIndexInFile ())); + appendJSON (sb, "selected", Boolean.toString (selectedEntries.contains (entry))); + // The last attribute must not be followed by a comma + sb.append (" \"path\": ").append (sourceFile == null ? "null" : quoteJSON (sourceFile.getAbsolutePath ())).append ('\n'); + sb.append (i == entries.size () - 1 ? " }\n" : " },\n"); + } + + return sb.append ("]\n").toString (); + } + + + /** + * Append one attribute of an object, followed by a comma. + * + * @param sb Where to append the attribute + * @param name The name of the attribute + * @param value The already formatted value of the attribute + */ + private static void appendJSON (final StringBuilder sb, final String name, final String value) + { + sb.append (" \"").append (name).append ("\": ").append (value).append (",\n"); + } + + + /** + * Format a list of names as a JSON array. + * + * @param values The names + * @return The formatted array + */ + private static String createJSONArray (final List values) + { + final StringBuilder sb = new StringBuilder ("["); + for (int i = 0; i < values.size (); i++) + { + if (i > 0) + sb.append (", "); + sb.append (quoteJSON (values.get (i))); + } + return sb.append (']').toString (); + } + + + /** + * Format a text as a JSON string with all characters escaped which are not allowed in it. + * + * @param text The text to format + * @return The quoted text + */ + private static String quoteJSON (final String text) + { + final StringBuilder sb = new StringBuilder ("\""); + for (int i = 0; i < text.length (); i++) + { + final char c = text.charAt (i); + switch (c) + { + case '"' -> sb.append ("\\\""); + case '\\' -> sb.append ("\\\\"); + case '\b' -> sb.append ("\\b"); + case '\f' -> sb.append ("\\f"); + case '\n' -> sb.append ("\\n"); + case '\r' -> sb.append ("\\r"); + case '\t' -> sb.append ("\\t"); + default -> { + if (c < 0x20) + sb.append (String.format ("\\u%04x", Integer.valueOf (c))); + else + sb.append (c); + } + } + } + return sb.append ('"').toString (); + } +} diff --git a/src/main/java/de/mossgrabers/convertwithmoss/ui/MainFrame.java b/src/main/java/de/mossgrabers/convertwithmoss/ui/MainFrame.java index 6b1beb56..68438fbe 100644 --- a/src/main/java/de/mossgrabers/convertwithmoss/ui/MainFrame.java +++ b/src/main/java/de/mossgrabers/convertwithmoss/ui/MainFrame.java @@ -199,7 +199,7 @@ public void initialise (final Stage stage, final Optional baseTitleOptio final Stage theStage = this.getStage (); this.settingsDialog = new SettingsDialog (theStage); this.processingDialog = new ProcessingDialog (theStage); - this.contentsDialog = new ContentsDialog (theStage); + this.contentsDialog = new ContentsDialog (theStage, this.config); // ----------------------------------------------------------- // The main button panel diff --git a/src/main/resources/Strings.properties b/src/main/resources/Strings.properties index 42e00cf9..586e489b 100644 --- a/src/main/resources/Strings.properties +++ b/src/main/resources/Strings.properties @@ -690,6 +690,14 @@ IDS_CONTENTS_AUDITION_TOOLTIP=Play one note of the highlighted preset - with its IDS_CONTENTS_AUDITION_READING=Reading %1... IDS_CONTENTS_AUDITION_FAILED=%1 could not be read. IDS_CONTENTS_AUDITION_SILENT=%1 makes no sound. +IDS_CONTENTS_EXPORT=Export List... +IDS_CONTENTS_EXPORT_TOOLTIP=Write the list of the presets shown here - each with its number of zones, key range, category, file and whether it is ticked - to a CSV or JSON file, e.g. to keep an inventory of a bank or a disk image or to process it with another application. Only the list is written, no preset is converted; a search filter applies to the list as well +IDS_CONTENTS_EXPORT_HEADER=Export Preset List +IDS_CONTENTS_EXPORT_CSV=Comma separated values (*.csv) +IDS_CONTENTS_EXPORT_JSON=JSON (*.json) +IDS_CONTENTS_EXPORT_FILE_NAME=Preset List +IDS_CONTENTS_EXPORT_DONE=List of %1 presets written to %2 +IDS_CONTENTS_EXPORT_FAILED=The preset list could not be written. IDS_CONTENTS_DLG_OK=OK IDS_CONTENTS_DLG_CANCEL=Cancel From 521e651a829d52b5dba6f751c953f19df0a0f5b2 Mon Sep 17 00:00:00 2001 From: Douglas Carmichael Date: Sun, 9 Aug 2026 18:24:42 -0400 Subject: [PATCH 2/3] Moved the changelog entry to a new 20.2.0 section, since 20.1.0 is released --- documentation/CHANGELOG.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/documentation/CHANGELOG.md b/documentation/CHANGELOG.md index cb66cd2e..46f5f16f 100644 --- a/documentation/CHANGELOG.md +++ b/documentation/CHANGELOG.md @@ -1,5 +1,10 @@ # Changes +## 20.2.0 (work-in-progress) + +* User Interface + * New: Contents dialog: The new 'Export List...' button writes the listed presets - not the presets themselves - as a CSV or JSON file with the name, category, number of zones, key range (as note names and MIDI note numbers), folder, file, containers, index inside of the file and the ticked state of each of them. This gives an inventory of a disk image, a bank or a preset folder which can be used in a spreadsheet or a script. A search filter applies to the written list as well. + ## 20.1.0 * Many thanks to Douglas Carmichael for plenty of contributions and fixes! @@ -9,7 +14,6 @@ * New: Added support for the Audiomodern Soundbox format (reading and writing of sound packs (SBPACK): every preset of a pack becomes one multi-sample with the key/velocity ranges, root notes, sample start/end, loops with cross-fade (also ping-pong), reverse flag, volume, panning and tuning of its sounds and the volume, panning, tuning and amplitude envelope of the layers folded in; round robin layers are read as round robin groups and round robin groups are written as round robin layers; a filter in one of the effect slots becomes the filter of the zones and the voice mode and glide become the polyphony and portamento; writing creates one pack with one preset per source and stores identical samples only once - import the written pack into the plug-in, verified with Soundbox 1.2.1). * User Interface * New: The folder/file history does now remember the selected source format for the folder/file and restores it on selection. - * New: Contents dialog: The new 'Export List...' button writes the listed presets - not the presets themselves - as a CSV or JSON file with the name, category, number of zones, key range (as note names and MIDI note numbers), folder, file, containers, index inside of the file and the ticked state of each of them. This gives an inventory of a disk image, a bank or a preset folder which can be used in a spreadsheet or a script. A search filter applies to the written list as well. * New: Contents dialog: The filter field can now be cleared with 'X' and has the focus when the dialog is opened. * Fixed: Contents dialog: Using 'Select All' on filtered content did still select all presets not only the filtered ones. * Fixed: Tabbing in dialogs did not work. From f3bd30e6f856ea9f2233a521b9ad281d04e613d8 Mon Sep 17 00:00:00 2001 From: Douglas Carmichael Date: Sun, 9 Aug 2026 18:33:39 -0400 Subject: [PATCH 3/3] Contents dialog: import a preset list to select the presets to convert 'Import List...' reads a written list back in and ticks exactly the presets which it selects, so the presets to convert can be picked in another application: export the list, tick what should be converted in e.g. a spreadsheet, save it as CSV again and import it. A row selects its preset when its 'Selected' field says so ('true', 'x', '1' or 'yes'); a list which has no such field selects every preset it contains, so deleting the rows which should not be converted works as well. Presets which the list does not mention are not ticked. A row is matched by its file and the index of the preset inside of it, so presets of the same name in different banks stay apart and a library which was moved to another folder is still found by its file name; a row which has only a name selects every preset of that name. Reading a CSV file goes through the header line, so its columns may be reordered or reduced, and semicolon or tabulator separated files - as a spreadsheet writes them in some countries - are read as well. Rows which match nothing are reported instead of stopping the import. --- documentation/CHANGELOG.md | 1 + documentation/README.md | 2 + .../convertwithmoss/ui/ContentsDialog.java | 61 +++++++++++++++++- .../convertwithmoss/ui/ContentsImporter.java | Bin 0 -> 15713 bytes src/main/resources/Strings.properties | 7 ++ 5 files changed, 69 insertions(+), 2 deletions(-) create mode 100644 src/main/java/de/mossgrabers/convertwithmoss/ui/ContentsImporter.java diff --git a/documentation/CHANGELOG.md b/documentation/CHANGELOG.md index 46f5f16f..8b54f30b 100644 --- a/documentation/CHANGELOG.md +++ b/documentation/CHANGELOG.md @@ -4,6 +4,7 @@ * User Interface * New: Contents dialog: The new 'Export List...' button writes the listed presets - not the presets themselves - as a CSV or JSON file with the name, category, number of zones, key range (as note names and MIDI note numbers), folder, file, containers, index inside of the file and the ticked state of each of them. This gives an inventory of a disk image, a bank or a preset folder which can be used in a spreadsheet or a script. A search filter applies to the written list as well. + * New: Contents dialog: The new 'Import List...' button reads such a list back in and ticks exactly the presets which it selects, so the presets to convert can be picked in another application: export the list, tick the presets in e.g. a spreadsheet, save it as CSV again and import it. A row selects its preset when its 'Selected' field says so ('true', 'x', '1' or 'yes'); a list without that field selects every preset it contains, so deleting rows works as well. Presets are matched by their file and their index inside of it, which tells presets of the same name in different banks apart and still finds a library which was moved to another folder. ## 20.1.0 diff --git a/documentation/README.md b/documentation/README.md index a78f71d2..aa091e39 100644 --- a/documentation/README.md +++ b/documentation/README.md @@ -112,6 +112,8 @@ This works for a format where one file simply is one preset as well, e.g. a fold *Export List...* writes the list itself - not the presets - to a CSV or JSON file, which is handy to keep an inventory of a disk image, a bank or a preset folder or to work with it in a spreadsheet or a script. Every listed preset becomes one line (CSV) or one object (JSON) with its name, category, number of zones, key range as note names and as MIDI note numbers, its folder, file and containers, its index inside of the file and whether it is ticked. The file format follows the ending you pick in the file dialog and a search filter applies to the export as well, so a filtered list writes only the presets which are shown. +*Import List...* reads such a list back in and ticks exactly the presets which it selects, which is the other half of picking the presets to convert in another application: export the list, open it in a spreadsheet, tick what should go onto your sampler, save it as CSV again and import it. A row selects its preset when its *Selected* field says so - `true`, `x`, `1` and `yes` all count, in any capitalisation - and if the list has no *Selected* field at all, every row of it selects its preset, so simply deleting the rows you do not want works as well. Presets which the list does not mention are not ticked. A row is matched by its file and the index inside of it, so presets of the same name in different banks stay apart and a library which has moved to another folder is still recognized by its file name; a row which has only a name selects every preset of that name. Semicolon separated files - as a spreadsheet writes them in some countries - are read as well, and rows which match nothing are reported instead of stopping the import. + Highlighting a preset and pressing *Play* - or double-clicking it - plays one note of it. The note is rendered from the preset as it was read, with its amplitude and filter envelopes, its filter and its LFOs applied, so you hear what the conversion produces and can pick the presets by ear instead of by name. The preset is read from its file again for this, which takes about a tenth of a second even inside a large disk image. Pressing *Play* again stops the note, as does closing the dialog. ## Processing diff --git a/src/main/java/de/mossgrabers/convertwithmoss/ui/ContentsDialog.java b/src/main/java/de/mossgrabers/convertwithmoss/ui/ContentsDialog.java index bfafb7e4..33ac2e26 100644 --- a/src/main/java/de/mossgrabers/convertwithmoss/ui/ContentsDialog.java +++ b/src/main/java/de/mossgrabers/convertwithmoss/ui/ContentsDialog.java @@ -17,6 +17,7 @@ import de.mossgrabers.convertwithmoss.core.ContentsEntry; import de.mossgrabers.convertwithmoss.core.IMultisampleSource; import de.mossgrabers.convertwithmoss.ui.ContentsExporter.Format; +import de.mossgrabers.convertwithmoss.ui.ContentsImporter.ImportResult; import de.mossgrabers.tools.FileUtils; import de.mossgrabers.tools.ui.BasicConfig; import de.mossgrabers.tools.ui.ControlFunctions; @@ -60,6 +61,7 @@ public class ContentsDialog extends PseudoModalDialog private Label statusLabel; private Button auditionButton; private Button exportButton; + private Button importButton; private List entries = new ArrayList<> (); private final Set selectedEntries = new HashSet<> (); private final Map entriesPerFile = new HashMap<> (); @@ -129,12 +131,17 @@ protected Pane init () this.exportButton.setOnAction (_ -> this.exportContents ()); this.exportButton.setDisable (true); + this.importButton = new Button (Functions.getText ("@IDS_CONTENTS_IMPORT")); + this.importButton.setTooltip (new Tooltip (Functions.getText ("@IDS_CONTENTS_IMPORT_TOOLTIP"))); + this.importButton.setOnAction (_ -> this.importContents ()); + this.importButton.setDisable (true); + this.auditionButton = new Button (Functions.getText ("@IDS_CONTENTS_AUDITION")); this.auditionButton.setTooltip (new Tooltip (Functions.getText ("@IDS_CONTENTS_AUDITION_TOOLTIP"))); this.auditionButton.setOnAction (_ -> this.toggleAudition ()); this.auditionButton.setDisable (true); - final HBox bottomRow = new HBox (this.selectionLabel, this.statusLabel, this.exportButton, this.auditionButton); + final HBox bottomRow = new HBox (this.selectionLabel, this.statusLabel, this.exportButton, this.importButton, this.auditionButton); bottomRow.getStyleClass ().addAll ("contentsToolbar", "contentsDialogRow"); bottomRow.setAlignment (Pos.CENTER_LEFT); HBox.setHgrow (this.statusLabel, Priority.ALWAYS); @@ -153,6 +160,7 @@ protected Pane init () this.traversalManager.add (selectNoneButton); this.traversalManager.add (this.treeView); this.traversalManager.add (this.exportButton); + this.traversalManager.add (this.importButton); this.traversalManager.add (this.auditionButton); this.traversalManager.add (this.getOkButton ()); this.traversalManager.add (this.getCancelButton ()); @@ -402,6 +410,53 @@ else if (fileName.endsWith (".csv")) } + /** + * Read a list which was written before - and possibly edited in another application, e.g. a + * spreadsheet - and tick exactly the presets which it selects. This is the other half of + * building a conversion list outside of ConvertWithMoss: export the contents, pick the presets + * there and import the result. Presets which the list does not mention are not ticked, so + * deleting the rows which should not be converted narrows the selection down as well. + */ + private void importContents () + { + if (this.entries.isEmpty ()) + return; + + final FileChooser chooser = new FileChooser (); + chooser.setTitle (Functions.getText ("@IDS_CONTENTS_IMPORT_HEADER")); + chooser.getExtensionFilters ().addAll (new ExtensionFilter (Functions.getText ("@IDS_CONTENTS_IMPORT_LISTS"), "*.csv", "*.json"), new ExtensionFilter (Functions.getText ("@IDS_CONTENTS_EXPORT_CSV"), "*.csv"), new ExtensionFilter (Functions.getText ("@IDS_CONTENTS_EXPORT_JSON"), "*.json")); + final String activePath = this.config.getActivePath (); + if (activePath != null) + { + final File activeFolder = new File (activePath); + if (activeFolder.isDirectory ()) + chooser.setInitialDirectory (activeFolder); + } + + final File file = chooser.showOpenDialog (this.owner); + if (file == null) + return; + final File parentFolder = file.getParentFile (); + if (parentFolder != null) + this.config.setActivePath (parentFolder); + + final Format format = file.getName ().toLowerCase (Locale.US).endsWith (".json") ? Format.JSON : Format.CSV; + try + { + final ImportResult result = ContentsImporter.importList (file, format, this.entries); + this.selectedEntries.clear (); + this.selectedEntries.addAll (result.selectedEntries ()); + this.fillTree (); + this.statusLabel.setText (result.numberOfUnmatchedRows () == 0 ? Functions.getMessage ("IDS_CONTENTS_IMPORT_DONE", Integer.toString (this.selectedEntries.size ()), file.getName ()) : Functions.getMessage ("IDS_CONTENTS_IMPORT_DONE_UNMATCHED", Integer.toString (this.selectedEntries.size ()), file.getName (), Integer.toString (result.numberOfUnmatchedRows ()))); + } + catch (final IOException | RuntimeException ex) + { + this.statusLabel.setText (""); + Functions.error ("@IDS_CONTENTS_IMPORT_FAILED", ex); + } + } + + /** * Collect all sources of a branch of the tree, in the order in which they are displayed. * @@ -477,7 +532,9 @@ private void fillTree () this.treeView.setRoot (root); // A folder is only created for a source which is displayed, therefore an empty root means // that there is nothing to export - this.exportButton.setDisable (root.getChildren ().isEmpty ()); + final boolean isEmpty = root.getChildren ().isEmpty (); + this.exportButton.setDisable (isEmpty); + this.importButton.setDisable (this.entries.isEmpty ()); this.updateSelectionLabel (); } diff --git a/src/main/java/de/mossgrabers/convertwithmoss/ui/ContentsImporter.java b/src/main/java/de/mossgrabers/convertwithmoss/ui/ContentsImporter.java new file mode 100644 index 0000000000000000000000000000000000000000..8c61d25a7bd194aab8a4821793d233c55b26b7dd GIT binary patch literal 15713 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 K%cq@BJO2U6yJMLE literal 0 HcmV?d00001 diff --git a/src/main/resources/Strings.properties b/src/main/resources/Strings.properties index 586e489b..a902dee5 100644 --- a/src/main/resources/Strings.properties +++ b/src/main/resources/Strings.properties @@ -698,6 +698,13 @@ IDS_CONTENTS_EXPORT_JSON=JSON (*.json) IDS_CONTENTS_EXPORT_FILE_NAME=Preset List IDS_CONTENTS_EXPORT_DONE=List of %1 presets written to %2 IDS_CONTENTS_EXPORT_FAILED=The preset list could not be written. +IDS_CONTENTS_IMPORT=Import List... +IDS_CONTENTS_IMPORT_TOOLTIP=Read a written preset list back in and tick exactly the presets which it selects - e.g. after picking them in a spreadsheet. A row selects its preset when its 'Selected' field says so ('true', 'x', '1' or 'yes'); if the list has no such field, every row of it selects its preset. Presets which the list does not mention are not ticked, so deleting rows narrows the selection down as well +IDS_CONTENTS_IMPORT_HEADER=Import Preset List +IDS_CONTENTS_IMPORT_LISTS=Preset lists (*.csv, *.json) +IDS_CONTENTS_IMPORT_DONE=%1 presets selected from %2 +IDS_CONTENTS_IMPORT_DONE_UNMATCHED=%1 presets selected from %2, %3 entries were not found +IDS_CONTENTS_IMPORT_FAILED=The preset list could not be read. IDS_CONTENTS_DLG_OK=OK IDS_CONTENTS_DLG_CANCEL=Cancel