diff --git a/documentation/CHANGELOG.md b/documentation/CHANGELOG.md index bb1fe3dc..85dc99c6 100644 --- a/documentation/CHANGELOG.md +++ b/documentation/CHANGELOG.md @@ -1,6 +1,6 @@ # Changes -# 20.2.0 (work-in-progress) +## 20.2.0 (work-in-progress) * New: Added support for the E-mu Emulator II (IMG, EMUIIFD, HFE). One disk becomes one multi-sample: the key map of the bank gives the zones with their key ranges and root keys and each voice its name, its loop and its audio, which is expanded from the companded bytes the sampler feeds to its AM6072 DAC. A bank which is larger than one floppy is reported as continuing on another disk. Read only. * New: A note is logged when a loop of a converted preset audibly clicks at its wrap-around point (the step back to the loop start is many times the normal movement of the waveform and a substantial part of the level). Sample libraries ship such loops surprisingly often - 17 of 152 presets across three commercial Ensoniq libraries - and without the note the first hint is the converted preset ticking on the destination device. Nothing is changed: the loop is written as the source authored it, and the note points to the snap-to-zero-crossing and loop cross-fade processing options which remove such clicks. A loop which already has a cross-fade is not reported. @@ -8,6 +8,9 @@ * Fixed: The sample and program files which a preset references are now looked up ignoring the upper/lower case of their name. Sampler file systems are case-insensitive and their CD-ROMs store all names in upper case, but a preset might well reference them in lower case. On a case-sensitive file system, which is the normal case on Linux, none of those samples could be found (e.g. 'STR SEC.6 -L.WAV' referenced as 'STR SEC.6 -L.wav'). This affects the Akai S5000/S6000 (AKP/AKM), Akai MESA, Akai MPC and MPC1000, FL Studio DirectWave, Teenage Engineering OP-XY and Synclavier Regen formats as well as all formats which use the common sample search (Ableton, Deluge, EXS24, NI Kontakt, NI Maschine, 1010music, SXT and TX16Wx) - the latter did only try the file ending in all upper and all lower case, so a name which differs in its case anywhere else was still not found. * Fixed: When a sample was not found next to its preset, the search jumped the configured number of folder levels up in one go and took whatever the recursive search found first. Two libraries below the same folder which name their samples alike - the normal case for the variants of a library - then fed a preset the sample of the other library, which sounds wrong and, since the preset keeps its own loop points, clicks at the loop. The search now goes up one level at a time and stops at the first level which contains the file, so a sample close to the preset always wins over one of the same name further away. * Fixed: The category names 'Winds' and 'World' were not detected when they were read back from a preset which stores the category as text: 'Winds' was detected as FX - from the 'Wind' sound effect - and 'World' was not detected at all. The name of a category is now a keyword of its own, which is checked for all of them. +* 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. * Akai MPC * Fixed: The Version element was created but never added to a written XPM file. Since it is the element by which the format is recognized, such a file was rejected when it was read back ("Could not read metadata: Unknown Root"). The platform is now written as well, so that the version log line is complete. * Fairlight CMI diff --git a/documentation/README.md b/documentation/README.md index d16ae898..4f25c169 100644 --- a/documentation/README.md +++ b/documentation/README.md @@ -110,6 +110,10 @@ 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. + +*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/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..33ac2e26 100644 --- a/src/main/java/de/mossgrabers/convertwithmoss/ui/ContentsDialog.java +++ b/src/main/java/de/mossgrabers/convertwithmoss/ui/ContentsDialog.java @@ -10,12 +10,16 @@ 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.convertwithmoss.ui.ContentsImporter.ImportResult; 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 +40,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 +58,10 @@ 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 Button importButton; private List entries = new ArrayList<> (); private final Set selectedEntries = new HashSet<> (); private final Map entriesPerFile = new HashMap<> (); @@ -63,15 +71,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 +122,29 @@ 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.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.auditionLabel, 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.auditionLabel, Priority.ALWAYS); + HBox.setHgrow (this.statusLabel, Priority.ALWAYS); pane.setTop (topRow); pane.setCenter (this.treeView); @@ -136,6 +159,8 @@ 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.importButton); this.traversalManager.add (this.auditionButton); this.traversalManager.add (this.getOkButton ()); this.traversalManager.add (this.getCancelButton ()); @@ -165,7 +190,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 +214,7 @@ private void toggleAudition () if (this.auditionPlayer.isPlaying ()) { this.auditionPlayer.stop (); - this.auditionLabel.setText (""); + this.statusLabel.setText (""); this.updateAuditionButton (); return; } @@ -209,7 +234,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 +257,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 +276,7 @@ private void endAudition () { if (this.isReading) return; - this.auditionLabel.setText (""); + this.statusLabel.setText (""); this.updateAuditionButton (); } @@ -322,6 +347,133 @@ 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); + } + } + + + /** + * 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. + * + * @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 +530,11 @@ 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 + 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/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/ContentsImporter.java b/src/main/java/de/mossgrabers/convertwithmoss/ui/ContentsImporter.java new file mode 100644 index 00000000..8c61d25a Binary files /dev/null and b/src/main/java/de/mossgrabers/convertwithmoss/ui/ContentsImporter.java differ diff --git a/src/main/java/de/mossgrabers/convertwithmoss/ui/MainFrame.java b/src/main/java/de/mossgrabers/convertwithmoss/ui/MainFrame.java index fdccfa93..0d5ff175 100644 --- a/src/main/java/de/mossgrabers/convertwithmoss/ui/MainFrame.java +++ b/src/main/java/de/mossgrabers/convertwithmoss/ui/MainFrame.java @@ -200,7 +200,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 109bbab5..1afbc795 100644 --- a/src/main/resources/Strings.properties +++ b/src/main/resources/Strings.properties @@ -693,6 +693,21 @@ 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_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