diff --git a/checkstyle-filter.xml b/checkstyle-filter.xml index bed492a..fd0a250 100644 --- a/checkstyle-filter.xml +++ b/checkstyle-filter.xml @@ -12,4 +12,8 @@ + + + diff --git a/com.io7m.laurel.filemodel/pom.xml b/com.io7m.laurel.filemodel/pom.xml index 2465c00..3560ed9 100644 --- a/com.io7m.laurel.filemodel/pom.xml +++ b/com.io7m.laurel.filemodel/pom.xml @@ -49,6 +49,23 @@ com.io7m.darco com.io7m.darco.api + + com.io7m.mime2045 + com.io7m.mime2045.core + + + com.io7m.mime2045 + com.io7m.mime2045.parser.api + + + com.io7m.mime2045 + com.io7m.mime2045.parser + + + com.io7m.mime2045 + com.io7m.mime2045.fileext + + org.jooq jooq diff --git a/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/LExportRequest.java b/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/LExportRequest.java new file mode 100644 index 0000000..ae4b279 --- /dev/null +++ b/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/LExportRequest.java @@ -0,0 +1,45 @@ +/* + * Copyright © 2024 Mark Raynsford https://www.io7m.com + * + * Permission to use, copy, modify, and/or distribute this software for any + * purpose with or without fee is hereby granted, provided that the above + * copyright notice and this permission notice appear in all copies. + * + * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY + * SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR + * IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + */ + + +package com.io7m.laurel.filemodel; + +import java.nio.file.Path; +import java.util.Objects; + +/** + * A request to export a dataset. + * + * @param outputDirectory The output directory + * @param exportImages {@code true} if images should be exported + */ + +public record LExportRequest( + Path outputDirectory, + boolean exportImages) +{ + /** + * A request to export a dataset. + * + * @param outputDirectory The output directory + * @param exportImages {@code true} if images should be exported + */ + + public LExportRequest + { + Objects.requireNonNull(outputDirectory, "outputDirectory"); + } +} diff --git a/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/LFileModelType.java b/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/LFileModelType.java index f26cb0a..991b5e4 100644 --- a/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/LFileModelType.java +++ b/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/LFileModelType.java @@ -532,4 +532,26 @@ CompletableFuture captionsPaste( */ AttributeReadableType> validationProblems(); + + /** + * Execute an export. + * + * @param request The export request + * + * @return The operation in progress + */ + + CompletableFuture export(LExportRequest request); + + /** + * @return The export events + */ + + AttributeReadableType> exportEvents(); + + /** + * Clear the export events. + */ + + void exportClear(); } diff --git a/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/internal/LCaptionFiles.java b/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/internal/LCaptionFiles.java new file mode 100644 index 0000000..ec2fd98 --- /dev/null +++ b/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/internal/LCaptionFiles.java @@ -0,0 +1,125 @@ +/* + * Copyright © 2024 Mark Raynsford https://www.io7m.com + * + * Permission to use, copy, modify, and/or distribute this software for any + * purpose with or without fee is hereby granted, provided that the above + * copyright notice and this permission notice appear in all copies. + * + * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY + * SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR + * IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + */ + + +package com.io7m.laurel.filemodel.internal; + +import com.io7m.laurel.model.LCaption; +import com.io7m.laurel.model.LCaptionName; +import com.io7m.laurel.model.LGlobalCaption; + +import java.nio.file.Files; +import java.nio.file.OpenOption; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import static java.nio.charset.StandardCharsets.UTF_8; + +/** + * Functions to parse and serialize caption files. + */ + +public final class LCaptionFiles +{ + private static final OpenOption[] OPEN_OPTIONS = { + StandardOpenOption.CREATE, + StandardOpenOption.WRITE, + StandardOpenOption.TRUNCATE_EXISTING, + }; + + private LCaptionFiles() + { + + } + + /** + * Parse a caption file. + * + * @param attributes The error attributes + * @param file The file + * + * @return The parsed caption names + * + * @throws Exception On errors + */ + + public static List parse( + final Map attributes, + final Path file) + throws Exception + { + attributes.put("File", file); + return parseCaptions(attributes, Files.readString(file)); + } + + private static List parseCaptions( + final Map attributes, + final String rawText) + { + final var results = + new ArrayList(); + + final var segments = + List.of(rawText.split(",")); + + for (final var text : segments) { + final var trimmed = text.trim(); + if (trimmed.isBlank()) { + continue; + } + attributes.put("Caption", trimmed); + results.add(new LCaptionName(trimmed)); + } + + return List.copyOf(results); + } + + /** + * Serialize captions. + * + * @param attributes The error attributes + * @param globalCaptions The global captions + * @param captions The captions + * @param outputFile The output file + * + * @throws Exception On errors + */ + + public static void serialize( + final Map attributes, + final List globalCaptions, + final List captions, + final Path outputFile) + throws Exception + { + attributes.put("File", outputFile); + + final var rawLines = + new ArrayList(globalCaptions.size() + captions.size()); + globalCaptions.forEach(x -> rawLines.add(x.caption().name().text())); + captions.forEach(x -> rawLines.add(x.name().text())); + + Files.writeString( + outputFile, + String.join(",\n", rawLines), + UTF_8, + OPEN_OPTIONS + ); + } +} diff --git a/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/internal/LCommandExport.java b/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/internal/LCommandExport.java new file mode 100644 index 0000000..fa33118 --- /dev/null +++ b/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/internal/LCommandExport.java @@ -0,0 +1,355 @@ +/* + * Copyright © 2024 Mark Raynsford https://www.io7m.com + * + * Permission to use, copy, modify, and/or distribute this software for any + * purpose with or without fee is hereby granted, provided that the above + * copyright notice and this permission notice appear in all copies. + * + * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY + * SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR + * IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + */ + + +package com.io7m.laurel.filemodel.internal; + +import com.io7m.laurel.filemodel.LExportRequest; +import com.io7m.laurel.filemodel.LFileModelEvent; +import com.io7m.laurel.filemodel.LFileModelEventError; +import com.io7m.laurel.filemodel.LFileModelEventType; +import com.io7m.laurel.model.LException; +import com.io7m.laurel.model.LGlobalCaption; +import com.io7m.laurel.model.LImageID; +import com.io7m.laurel.model.LImageWithID; +import com.io7m.mime2045.core.MimeType; +import com.io7m.mime2045.fileext.MimeFileExtensions; +import org.jooq.DSLContext; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.OptionalDouble; +import java.util.Properties; +import java.util.stream.Collectors; + +import static com.io7m.laurel.filemodel.internal.Tables.IMAGES; +import static com.io7m.laurel.filemodel.internal.Tables.IMAGE_BLOBS; + +/** + * Export. + */ + +public final class LCommandExport + extends LCommandAbstract +{ + private final HashMap attributes; + private int imageCount; + private int imageIndex; + private final ArrayList events; + + /** + * Export. + */ + + public LCommandExport() + { + this.events = new ArrayList<>(); + this.attributes = new HashMap(); + } + + /** + * Validate. + * + * @return A command factory + */ + + public static LCommandFactoryType provider() + { + return new LCommandFactory<>( + LCommandExport.class.getCanonicalName(), + LCommandExport::fromProperties + ); + } + + private static LCommandExport fromProperties( + final Properties p) + { + final var c = new LCommandExport(); + c.setExecuted(true); + return c; + } + + @Override + protected LCommandUndoable onExecute( + final LFileModel model, + final LDatabaseTransactionType transaction, + final LExportRequest request) + throws LException + { + final var context = + transaction.get(DSLContext.class); + + try { + this.createOutputDirectory(request.outputDirectory()); + } catch (final LException e) { + throw this.handleException(e); + } + + try { + final var images = model.imageList().get(); + this.imageCount = images.size(); + this.imageIndex = 0; + + for (final var image : images) { + this.exportImage( + model, + context, + model.globalCaptionList().get(), + image, + request + ); + ++this.imageIndex; + } + + this.event(model, 1.0, "Exported dataset."); + return LCommandUndoable.COMMAND_NOT_UNDOABLE; + } catch (final Throwable e) { + throw this.handleException(e); + } + } + + private LException mapException( + final Throwable e) + { + if (e instanceof final LException es) { + return es; + } + + return new LException( + Objects.requireNonNullElse(e.getMessage(), e.getClass().getSimpleName()), + e, + "error-exception", + this.takeAttributes(), + Optional.empty() + ); + } + + private Map takeAttributes() + { + final var attributeMap = this.attributesCopy(); + this.attributes.clear(); + return attributeMap; + } + + + private LException handleException( + final Throwable e) + { + final var x = this.mapException(e); + this.events.add( + new LFileModelEventError( + x.getMessage(), + OptionalDouble.of(0.0), + x.errorCode().toString(), + x.attributes(), + x.remediatingAction(), + Optional.of(x) + ) + ); + return x; + } + + private Map attributesCopy() + { + return this.attributes.entrySet() + .stream() + .map(x -> Map.entry(x.getKey(), x.getValue().toString())) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + } + + private void event( + final LFileModel model, + final double progress, + final String text, + final Object... arguments) + { + this.events.add( + new LFileModelEvent( + text.formatted(arguments), + OptionalDouble.of(progress) + ) + ); + + model.setExportEvents(List.copyOf(this.events)); + } + + private void eventWithProgress( + final LFileModel model, + final String text, + final Object... arguments) + { + this.events.add( + new LFileModelEvent( + text.formatted(arguments), + OptionalDouble.of((double) this.imageIndex / (double) this.imageCount) + ) + ); + + model.setExportEvents(List.copyOf(this.events)); + } + + private void exportImage( + final LFileModel model, + final DSLContext context, + final List globalCaptions, + final LImageWithID image, + final LExportRequest request) + throws LException + { + final var idString = + Long.toUnsignedString(image.id().value()); + final var idStringZeroed = + "0".repeat(20 - idString.length()); + final var imageNumber = + "%s%s".formatted(idStringZeroed, idString); + final var imageExt = + imageExtensionFor(image.image().type()); + final var imageName = + "%s.%s".formatted(imageNumber, imageExt); + final var captionName = + "%s.caption".formatted(imageNumber); + + if (request.exportImages()) { + this.writeImage( + context, + model, + image, + request.outputDirectory().resolve(imageName) + ); + } + + this.writeCaptions( + model, + context, + globalCaptions, + image, + request.outputDirectory().resolve(captionName) + ); + } + + private void writeCaptions( + final LFileModel model, + final DSLContext context, + final List globalCaptions, + final LImageWithID image, + final Path file) + throws LException + { + this.eventWithProgress(model, "Writing caption file '%s'", file); + this.attributes.put("Image", image.id()); + + try { + LCaptionFiles.serialize( + this.attributes, + globalCaptions, + LCommandModelUpdates.listImageCaptionsAssigned(context, image.id()), + file + ); + } catch (final Exception e) { + throw this.handleException(e); + } + } + + private void writeImage( + final DSLContext context, + final LFileModel model, + final LImageWithID image, + final Path file) + throws LException + { + this.eventWithProgress(model, "Writing image file '%s'", file); + this.attributes.put("Image", image.id()); + this.attributes.put("File", file); + + try { + Files.write(file, imageData(context, image.id())); + } catch (final Exception e) { + throw this.handleException(e); + } + } + + private static byte[] imageData( + final DSLContext context, + final LImageID id) + { + return context.select(IMAGE_BLOBS.IMAGE_BLOB_DATA) + .from(IMAGE_BLOBS) + .join(IMAGES) + .on(IMAGES.IMAGE_BLOB.eq(IMAGE_BLOBS.IMAGE_BLOB_ID)) + .where(IMAGES.IMAGE_ID.eq(id.value())) + .fetchOne(IMAGE_BLOBS.IMAGE_BLOB_DATA); + } + + private static String imageExtensionFor( + final MimeType type) + { + return MimeFileExtensions.suggestFileExtension(type) + .orElse("bin"); + } + + private void createOutputDirectory( + final Path outputDirectory) + throws LException + { + try { + this.attributes.put("Output Directory", outputDirectory); + Files.createDirectories(outputDirectory); + } catch (final IOException e) { + throw new LException( + e.getMessage(), + e, + "error-create-directory", + this.takeAttributes(), + Optional.empty() + ); + } + } + + @Override + protected void onUndo( + final LFileModel model, + final LDatabaseTransactionType transaction) + { + throw new UnsupportedOperationException(); + } + + @Override + protected void onRedo( + final LFileModel model, + final LDatabaseTransactionType transaction) + { + throw new UnsupportedOperationException(); + } + + @Override + public Properties toProperties() + { + return new Properties(); + } + + @Override + public String describe() + { + return "Export"; + } +} diff --git a/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/internal/LCommandImageSelect.java b/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/internal/LCommandImageSelect.java index da23d33..4d5f33f 100644 --- a/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/internal/LCommandImageSelect.java +++ b/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/internal/LCommandImageSelect.java @@ -20,15 +20,18 @@ import com.io7m.laurel.model.LCaption; import com.io7m.laurel.model.LCaptionID; import com.io7m.laurel.model.LCaptionName; +import com.io7m.laurel.model.LException; import com.io7m.laurel.model.LHashSHA256; import com.io7m.laurel.model.LImage; import com.io7m.laurel.model.LImageID; import com.io7m.laurel.model.LImageWithID; +import com.io7m.mime2045.parser.api.MimeParseException; import org.jooq.DSLContext; import java.net.URI; import java.nio.file.Paths; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.Properties; @@ -89,6 +92,7 @@ protected LCommandUndoable onExecute( final LFileModel model, final LDatabaseTransactionType transaction, final Optional request) + throws LException { final var context = transaction.get(DSLContext.class); @@ -109,7 +113,8 @@ protected LCommandUndoable onExecute( IMAGES.IMAGE_ID, IMAGES.IMAGE_NAME, IMAGES.IMAGE_SOURCE, - IMAGE_BLOBS.IMAGE_BLOB_SHA256 + IMAGE_BLOBS.IMAGE_BLOB_SHA256, + IMAGE_BLOBS.IMAGE_BLOB_TYPE ).from(IMAGES) .join(IMAGE_BLOBS) .on(IMAGE_BLOBS.IMAGE_BLOB_ID.eq(IMAGES.IMAGE_ID)) @@ -142,21 +147,28 @@ protected LCommandUndoable onExecute( .toList(); model.setImageCaptionsAssigned(captions); - model.setImageSelected( - Optional.of( - new LImageWithID( - imageId, - new LImage( - imageRec.get(IMAGES.IMAGE_NAME), - Optional.ofNullable(imageRec.get(IMAGES.IMAGE_FILE)) - .map(Paths::get), - Optional.ofNullable(imageRec.get(IMAGES.IMAGE_SOURCE)) - .map(URI::create), - new LHashSHA256(imageRec.get(IMAGE_BLOBS.IMAGE_BLOB_SHA256)) + try { + model.setImageSelected( + Optional.of( + new LImageWithID( + imageId, + new LImage( + imageRec.get(IMAGES.IMAGE_NAME), + Optional.ofNullable(imageRec.get(IMAGES.IMAGE_FILE)) + .map(Paths::get), + Optional.ofNullable(imageRec.get(IMAGES.IMAGE_SOURCE)) + .map(URI::create), + LCommandModelUpdates.MIME_PARSERS.parse( + imageRec.get(IMAGE_BLOBS.IMAGE_BLOB_TYPE) + ), + new LHashSHA256(imageRec.get(IMAGE_BLOBS.IMAGE_BLOB_SHA256)) + ) ) ) - ) - ); + ); + } catch (final MimeParseException e) { + throw new LException(e, "error-mime", Map.of(), Optional.empty()); + } return LCommandUndoable.COMMAND_NOT_UNDOABLE; } diff --git a/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/internal/LCommandImagesAdd.java b/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/internal/LCommandImagesAdd.java index 68f2eb0..6fb81da 100644 --- a/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/internal/LCommandImagesAdd.java +++ b/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/internal/LCommandImagesAdd.java @@ -19,6 +19,8 @@ import com.io7m.laurel.model.LException; import com.io7m.laurel.model.LHashSHA256; +import com.io7m.mime2045.core.MimeType; +import org.apache.tika.Tika; import org.jooq.DSLContext; import javax.imageio.ImageIO; @@ -31,6 +33,7 @@ import java.util.ArrayList; import java.util.HexFormat; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.Properties; @@ -44,6 +47,9 @@ public final class LCommandImagesAdd extends LCommandAbstract> { + private static final Tika TIKA = + new Tika(); + private final ArrayList savedData; private record SavedData( @@ -136,10 +142,19 @@ protected LCommandUndoable onExecute( final var imageHash = hashOf(imageBytes); + final MimeType type; + try { + final var typeText = TIKA.detect(file); + type = LCommandModelUpdates.MIME_PARSERS.parse(typeText); + } catch (final Exception e) { + throw new LException(e, "error-mime", Map.of(), Optional.empty()); + } + final var blobRec = context.insertInto(IMAGE_BLOBS) .set(IMAGE_BLOBS.IMAGE_BLOB_SHA256, imageHash.value()) .set(IMAGE_BLOBS.IMAGE_BLOB_DATA, imageBytes) + .set(IMAGE_BLOBS.IMAGE_BLOB_TYPE, type.toString()) .returning(IMAGE_BLOBS.IMAGE_BLOB_ID) .fetchOne(); diff --git a/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/internal/LCommandModelUpdates.java b/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/internal/LCommandModelUpdates.java index 014c44d..4bcbf39 100644 --- a/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/internal/LCommandModelUpdates.java +++ b/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/internal/LCommandModelUpdates.java @@ -29,6 +29,8 @@ import com.io7m.laurel.model.LImageID; import com.io7m.laurel.model.LImageWithID; import com.io7m.laurel.model.LMetadataValue; +import com.io7m.mime2045.parser.MimeParsers; +import com.io7m.mime2045.parser.api.MimeParseException; import org.jooq.DSLContext; import org.jooq.Field; import org.jooq.impl.DSL; @@ -58,6 +60,9 @@ public final class LCommandModelUpdates { + static final MimeParsers MIME_PARSERS = + new MimeParsers(); + static final Field COUNT_FIELD = DSL.coalesce(IMAGE_CAPTIONS_COUNTS.COUNT_CAPTION_COUNT, 0L) .as(IMAGE_CAPTIONS_COUNTS.COUNT_CAPTION_COUNT); @@ -75,7 +80,8 @@ static List listImages( IMAGES.IMAGE_SOURCE, IMAGES.IMAGE_NAME, IMAGES.IMAGE_FILE, - IMAGE_BLOBS.IMAGE_BLOB_SHA256 + IMAGE_BLOBS.IMAGE_BLOB_SHA256, + IMAGE_BLOBS.IMAGE_BLOB_TYPE ) .from(IMAGES) .join(IMAGE_BLOBS) @@ -89,15 +95,20 @@ static List listImages( private static LImageWithID mapImageRecord( final org.jooq.Record r) { - return new LImageWithID( - new LImageID(r.get(IMAGES.IMAGE_ID).longValue()), - new LImage( - r.get(IMAGES.IMAGE_NAME), - Optional.ofNullable(r.get(IMAGES.IMAGE_FILE)).map(Paths::get), - Optional.ofNullable(r.get(IMAGES.IMAGE_SOURCE)).map(URI::create), - new LHashSHA256(r.get(IMAGE_BLOBS.IMAGE_BLOB_SHA256)) - ) - ); + try { + return new LImageWithID( + new LImageID(r.get(IMAGES.IMAGE_ID).longValue()), + new LImage( + r.get(IMAGES.IMAGE_NAME), + Optional.ofNullable(r.get(IMAGES.IMAGE_FILE)).map(Paths::get), + Optional.ofNullable(r.get(IMAGES.IMAGE_SOURCE)).map(URI::create), + MIME_PARSERS.parse(r.get(IMAGE_BLOBS.IMAGE_BLOB_TYPE)), + new LHashSHA256(r.get(IMAGE_BLOBS.IMAGE_BLOB_SHA256)) + ) + ); + } catch (final MimeParseException e) { + throw new IllegalStateException(e); + } } static List listCaptionsAll( diff --git a/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/internal/LFileModel.java b/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/internal/LFileModel.java index 793d624..6e3d87b 100644 --- a/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/internal/LFileModel.java +++ b/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/internal/LFileModel.java @@ -28,6 +28,7 @@ import com.io7m.jmulticlose.core.CloseableCollection; import com.io7m.jmulticlose.core.CloseableCollectionType; import com.io7m.laurel.filemodel.LCategoryCaptionsAssignment; +import com.io7m.laurel.filemodel.LExportRequest; import com.io7m.laurel.filemodel.LFileModelEvent; import com.io7m.laurel.filemodel.LFileModelEventType; import com.io7m.laurel.filemodel.LFileModelType; @@ -129,6 +130,7 @@ public final class LFileModel implements LFileModelType private final ReentrantLock commandLock; private final SubmissionPublisher events; private final AttributeType> validationProblems; + private final AttributeType> exportEvents; private LFileModel( final LDatabaseType inDatabase) @@ -183,6 +185,8 @@ private LFileModel( ATTRIBUTES.withValue(Optional.empty()); this.redoText = this.redo.map(o -> o.map(LCommandType::describe)); + this.exportEvents = + ATTRIBUTES.withValue(List.of()); this.validationProblems = ATTRIBUTES.withValue(List.of()); this.commandLock = @@ -1197,6 +1201,30 @@ public AttributeReadableType> validationProblems() return this.validationProblems; } + @Override + public CompletableFuture export( + final LExportRequest request) + { + Objects.requireNonNull(request, "request"); + + return this.runCommand( + new LCommandExport(), + request + ); + } + + @Override + public AttributeReadableType> exportEvents() + { + return this.exportEvents; + } + + @Override + public void exportClear() + { + this.exportEvents.set(List.of()); + } + private Optional executeImageStream( final LImageID id) throws LException @@ -1459,4 +1487,10 @@ void setValidationProblems( { this.validationProblems.set(problems); } + + void setExportEvents( + final List newEvents) + { + this.exportEvents.set(newEvents); + } } diff --git a/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/internal/LFileModelImport.java b/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/internal/LFileModelImport.java index 491d848..7a60349 100644 --- a/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/internal/LFileModelImport.java +++ b/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/internal/LFileModelImport.java @@ -316,9 +316,10 @@ private void openCaptions() this.attributes.put("File", capFile); this.eventProgress(index, max, "Loading caption file %s.", capFile); - final List captionList = - this.parseCaptions(Files.readString(capFile)); - this.captions.put(imageFile, captionList); + this.captions.put( + imageFile, + LCaptionFiles.parse(this.attributes, capFile) + ); } catch (final Throwable e) { this.failed.set(true); this.handleException(e); @@ -340,32 +341,6 @@ private LException errorImport() ); } - private List parseCaptions( - final String rawText) - { - final var results = - new ArrayList(); - - final var segments = - List.of(rawText.split(",")); - - for (final var text : segments) { - final var trimmed = text.trim(); - if (trimmed.isBlank()) { - continue; - } - this.attributes.put("Caption", trimmed); - try { - results.add(new LCaptionName(trimmed)); - } catch (final Throwable e) { - this.failed.set(true); - this.handleException(e); - } - } - - return List.copyOf(results); - } - private void listFiles() throws LException { diff --git a/com.io7m.laurel.filemodel/src/main/java/module-info.java b/com.io7m.laurel.filemodel/src/main/java/module-info.java index e58c6d8..82fe64d 100644 --- a/com.io7m.laurel.filemodel/src/main/java/module-info.java +++ b/com.io7m.laurel.filemodel/src/main/java/module-info.java @@ -54,7 +54,12 @@ requires com.io7m.jmulticlose.core; requires com.io7m.lanark.core; requires com.io7m.laurel.model; + requires com.io7m.mime2045.core; + requires com.io7m.mime2045.fileext; + requires com.io7m.mime2045.parser.api; + requires com.io7m.mime2045.parser; requires com.io7m.seltzer.api; + requires io.opentelemetry.api; requires java.desktop; requires org.apache.commons.io; diff --git a/com.io7m.laurel.filemodel/src/main/resources/com/io7m/laurel/filemodel/internal/database.xml b/com.io7m.laurel.filemodel/src/main/resources/com/io7m/laurel/filemodel/internal/database.xml index 09cf44b..14bd568 100644 --- a/com.io7m.laurel.filemodel/src/main/resources/com/io7m/laurel/filemodel/internal/database.xml +++ b/com.io7m.laurel.filemodel/src/main/resources/com/io7m/laurel/filemodel/internal/database.xml @@ -57,7 +57,8 @@ STRICT CREATE TABLE image_blobs ( image_blob_id INTEGER PRIMARY KEY NOT NULL, image_blob_data BLOB NOT NULL, - image_blob_sha256 TEXT NOT NULL + image_blob_sha256 TEXT NOT NULL, + image_blob_type TEXT NOT NULL ) -- [jooq ignore start] STRICT diff --git a/com.io7m.laurel.gui/src/main/java/com/io7m/laurel/gui/internal/LAbout.java b/com.io7m.laurel.gui/src/main/java/com/io7m/laurel/gui/internal/LAbout.java index af50353..b7842a9 100644 --- a/com.io7m.laurel.gui/src/main/java/com/io7m/laurel/gui/internal/LAbout.java +++ b/com.io7m.laurel.gui/src/main/java/com/io7m/laurel/gui/internal/LAbout.java @@ -91,7 +91,7 @@ public static LAbout open( final var stage = new Stage(); final var layout = - LExporterDialogs.class.getResource( + LAbout.class.getResource( "/com/io7m/laurel/gui/internal/about.fxml"); Objects.requireNonNull(layout, "layout"); diff --git a/com.io7m.laurel.gui/src/main/java/com/io7m/laurel/gui/internal/LApplication.java b/com.io7m.laurel.gui/src/main/java/com/io7m/laurel/gui/internal/LApplication.java index 3894dad..c6cf500 100644 --- a/com.io7m.laurel.gui/src/main/java/com/io7m/laurel/gui/internal/LApplication.java +++ b/com.io7m.laurel.gui/src/main/java/com/io7m/laurel/gui/internal/LApplication.java @@ -84,9 +84,6 @@ public void start( final var metadataEditors = new LMetadataEditors(strings); services.register(LMetadataEditors.class, metadataEditors); - final var exporters = new LExporterDialogs(services, strings); - services.register(LExporterDialogs.class, exporters); - final var choosers = new LFileChoosers(services); services.register(LFileChoosersType.class, choosers); diff --git a/com.io7m.laurel.gui/src/main/java/com/io7m/laurel/gui/internal/LExporterDialogs.java b/com.io7m.laurel.gui/src/main/java/com/io7m/laurel/gui/internal/LExporterDialogs.java deleted file mode 100644 index babfd01..0000000 --- a/com.io7m.laurel.gui/src/main/java/com/io7m/laurel/gui/internal/LExporterDialogs.java +++ /dev/null @@ -1,155 +0,0 @@ -/* - * Copyright © 2023 Mark Raynsford https://www.io7m.com - * - * Permission to use, copy, modify, and/or distribute this software for any - * purpose with or without fee is hereby granted, provided that the above - * copyright notice and this permission notice appear in all copies. - * - * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES - * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF - * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY - * SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES - * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN - * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR - * IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. - */ - - -package com.io7m.laurel.gui.internal; - -import com.io7m.repetoir.core.RPServiceDirectoryType; -import com.io7m.repetoir.core.RPServiceType; -import javafx.beans.property.SimpleBooleanProperty; -import javafx.beans.property.SimpleObjectProperty; -import javafx.fxml.FXMLLoader; -import javafx.scene.Scene; -import javafx.scene.layout.Pane; -import javafx.stage.Stage; - -import java.io.IOException; -import java.io.UncheckedIOException; -import java.nio.file.Path; -import java.util.Objects; - -import static javafx.stage.Modality.APPLICATION_MODAL; - -/** - * A service for creating exporter dialogs. - */ - -public final class LExporterDialogs implements RPServiceType -{ - private final LStrings strings; - private final RPServiceDirectoryType services; - private final SimpleObjectProperty directory; - private final SimpleBooleanProperty recentIncludeImages; - - /** - * A service for creating exporter dialogs. - * - * @param inServices The services - * @param inStrings The string resources - */ - - public LExporterDialogs( - final RPServiceDirectoryType inServices, - final LStrings inStrings) - { - this.services = - Objects.requireNonNull(inServices, "services"); - this.strings = - Objects.requireNonNull(inStrings, "inStrings"); - this.directory = - new SimpleObjectProperty<>(); - this.recentIncludeImages = - new SimpleBooleanProperty(); - } - - /** - * Open a dialog. - * - * @param fileModel The file model - * - * @return The dialog - */ - - public LExporterView open( - final LFileModelScope fileModel) - { - Objects.requireNonNull(fileModel, "fileModel"); - - try { - final var stage = new Stage(); - - final var layout = - LExporterDialogs.class.getResource( - "/com/io7m/laurel/gui/internal/exporter.fxml"); - - Objects.requireNonNull(layout, "layout"); - - final var loader = - new FXMLLoader(layout, this.strings.resources()); - - final var exporter = - new LExporterView( - this, - this.services, - fileModel, - stage - ); - - loader.setControllerFactory(param -> { - return exporter; - }); - - final Pane pane = loader.load(); - LCSS.setCSS(pane); - - stage.initModality(APPLICATION_MODAL); - stage.setTitle(this.strings.format("export")); - stage.setWidth(640.0); - stage.setHeight(192.0); - stage.setMinWidth(640.0); - stage.setMinHeight(192.0); - stage.setScene(new Scene(pane)); - stage.showAndWait(); - - return exporter; - } catch (final IOException e) { - throw new UncheckedIOException(e); - } - } - - @Override - public String toString() - { - return String.format( - "[LExporterDialogs 0x%08x]", - Integer.valueOf(this.hashCode()) - ); - } - - @Override - public String description() - { - return "Exporter dialog service"; - } - - /** - * @return The most recent directory - */ - - public SimpleObjectProperty directoryProperty() - { - return this.directory; - } - - /** - * @return The most recent include images setting - */ - - public SimpleBooleanProperty recentIncludeImagesProperty() - { - return this.recentIncludeImages; - } -} diff --git a/com.io7m.laurel.gui/src/main/java/com/io7m/laurel/gui/internal/LExporterView.java b/com.io7m.laurel.gui/src/main/java/com/io7m/laurel/gui/internal/LExporterView.java index 6ac3a4e..e689e96 100644 --- a/com.io7m.laurel.gui/src/main/java/com/io7m/laurel/gui/internal/LExporterView.java +++ b/com.io7m.laurel.gui/src/main/java/com/io7m/laurel/gui/internal/LExporterView.java @@ -17,22 +17,48 @@ package com.io7m.laurel.gui.internal; +import com.io7m.jmulticlose.core.CloseableCollection; import com.io7m.jmulticlose.core.CloseableCollectionType; +import com.io7m.jmulticlose.core.ClosingResourceFailedException; import com.io7m.jwheatsheaf.api.JWFileChooserAction; import com.io7m.jwheatsheaf.api.JWFileChooserConfiguration; -import com.io7m.jwheatsheaf.oxygen.JWOxygenIconSet; +import com.io7m.laurel.filemodel.LExportRequest; +import com.io7m.laurel.filemodel.LFileModelEvent; +import com.io7m.laurel.filemodel.LFileModelEventError; +import com.io7m.laurel.filemodel.LFileModelEventType; import com.io7m.laurel.filemodel.LFileModelType; import com.io7m.repetoir.core.RPServiceDirectoryType; +import javafx.application.Platform; +import javafx.beans.property.ReadOnlyStringWrapper; +import javafx.collections.FXCollections; import javafx.fxml.FXML; +import javafx.fxml.FXMLLoader; +import javafx.scene.Scene; import javafx.scene.control.Button; import javafx.scene.control.CheckBox; +import javafx.scene.control.Label; +import javafx.scene.control.ListView; +import javafx.scene.control.ProgressBar; +import javafx.scene.control.TableColumn; +import javafx.scene.control.TableView; +import javafx.scene.control.TextArea; import javafx.scene.control.TextField; -import javafx.stage.Modality; +import javafx.scene.layout.Pane; import javafx.stage.Stage; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; -import java.nio.file.Path; +import java.io.IOException; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.nio.file.Paths; +import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.Optional; +import java.util.concurrent.atomic.AtomicBoolean; + +import static com.io7m.laurel.gui.internal.LStringConstants.TITLE; /** * The exporter view. @@ -40,73 +66,77 @@ public final class LExporterView extends LAbstractViewWithModel { - private final Stage stage; - private final LPreferencesType preferences; - private Optional result; - private final LExporterDialogs dialogs; + private static final Logger LOG = + LoggerFactory.getLogger(LExporterView.class); + private final LFileChoosersType fileChoosers; + private final Stage stage; + private final CloseableCollectionType resources; + private final AtomicBoolean running; + private final RPServiceDirectoryType services; - @FXML private Button cancel; - @FXML private Button export; + @FXML private Button select; + @FXML private Button exportButton; @FXML private TextField directoryField; - @FXML private CheckBox includeImages; + @FXML private TextArea exceptionArea; + @FXML private CheckBox exportImages; + @FXML private ProgressBar progress; + @FXML private TableView> attributeTable; + @FXML private TableColumn, String> attributeName; + @FXML private TableColumn, String> attributeValue; + @FXML private ListView eventList; /** * The exporter view. * - * @param inDialogs The dialogs - * @param fileModel The file model - * @param inServices The service directory - * @param inStage The stage + * @param inServices The services + * @param inFileScope The file scope + * @param inStage The stage */ public LExporterView( - final LExporterDialogs inDialogs, final RPServiceDirectoryType inServices, - final LFileModelScope fileModel, + final LFileModelScope inFileScope, final Stage inStage) { - super(fileModel); + super(inFileScope); - this.dialogs = - Objects.requireNonNull(inDialogs, "dialogs"); + this.services = + Objects.requireNonNull(inServices, "services"); this.fileChoosers = inServices.requireService(LFileChoosersType.class); - this.preferences = - inServices.requireService(LPreferencesType.class); this.stage = Objects.requireNonNull(inStage, "stage"); - this.result = - Optional.empty(); - } - - /** - * @return The resulting request, if any - */ - - public Optional result() - { - return this.result; + this.resources = + CloseableCollection.create(); + this.running = + new AtomicBoolean(false); } @Override protected void onInitialize() { - this.export.setDisable(true); + this.exportButton.setDisable(true); - this.dialogs.directoryProperty() - .addListener((observable, oldValue, newValue) -> { - this.updateDirectoryField(newValue); - }); + this.directoryField.textProperty() + .addListener((_0, _1, _2) -> this.validate()); - this.includeImages.selectedProperty() - .bindBidirectional(this.dialogs.recentIncludeImagesProperty()); + this.attributeTable.setPlaceholder(new Label("")); + this.attributeName.setCellValueFactory(param -> { + return new ReadOnlyStringWrapper(param.getValue().getKey()); + }); + this.attributeValue.setCellValueFactory(param -> { + return new ReadOnlyStringWrapper(param.getValue().getValue()); + }); - this.updateDirectoryField( - this.dialogs.directoryProperty().get() - ); + this.eventList.setCellFactory(v -> new LEventCell(this.services)); + this.eventList.getSelectionModel() + .selectedItemProperty() + .addListener((_0, _1, item) -> { + this.onEventSelectionChanged(item); + }); - this.cancel.requestFocus(); + this.stage.setOnHidden(windowEvent -> this.close()); } @Override @@ -118,58 +148,177 @@ protected void onFileBecameUnavailable() @Override protected void onFileBecameAvailable( final CloseableCollectionType subscriptions, - final LFileModelType fileModel) + final LFileModelType model) { + model.exportClear(); + subscriptions.add( + model.exportEvents() + .subscribe((_0, newValues) -> { + Platform.runLater(() -> { + this.eventList.setItems(FXCollections.observableList(newValues)); + }); + }) + ); } - private void updateDirectoryField( - final Path newValue) + private void onEventSelectionChanged( + final LFileModelEventType item) { - if (newValue == null) { - this.directoryField.setText(""); - this.export.setDisable(true); - } else { - this.preferences.addRecentFile(newValue); - this.directoryField.setText(newValue.toString()); - this.export.setDisable(false); + switch (item) { + case null -> { + this.attributeTable.setItems(FXCollections.emptyObservableList()); + this.exceptionArea.setText(""); + } + case final LFileModelEvent ignored -> { + this.attributeTable.setItems(FXCollections.emptyObservableList()); + this.exceptionArea.setText(""); + } + case final LFileModelEventError error -> { + this.attributeTable.setItems( + FXCollections.observableList( + List.copyOf(error.attributes().entrySet()) + ) + ); + this.exceptionArea.setText(exceptionTextOf(error.exception())); + } } } + private static String exceptionTextOf( + final Optional exceptionOpt) + { + if (exceptionOpt.isEmpty()) { + return ""; + } + + final var exception = exceptionOpt.get(); + try (var writer = new StringWriter()) { + try (var printWriter = new PrintWriter(writer)) { + exception.printStackTrace(printWriter); + printWriter.flush(); + } + return writer.toString(); + } catch (final IOException e) { + throw new IllegalStateException(e); + } + } + + private void close() + { + try { + this.resources.close(); + } catch (final ClosingResourceFailedException e) { + // Nothing we can do! + } + } + + private void validate() + { + if (this.running.get()) { + this.directoryField.setDisable(true); + this.exportButton.setDisable(true); + this.exportImages.setDisable(true); + this.select.setDisable(true); + return; + } + + final var ok = !this.directoryField.getText().isBlank(); + this.exportButton.setDisable(!ok); + this.directoryField.setDisable(false); + this.exportButton.setDisable(false); + this.exportImages.setDisable(false); + this.select.setDisable(false); + } + @FXML - private void onSelect() - throws Exception + private void onSelectDirectorySelected() { - final var fileChooser = + final var chooser = this.fileChoosers.create( JWFileChooserConfiguration.builder() - .setModality(Modality.APPLICATION_MODAL) .setAction(JWFileChooserAction.OPEN_EXISTING_SINGLE) - .setRecentFiles(this.preferences.recentFiles()) - .setCssStylesheet(LCSS.defaultCSS().toURL()) - .setFileImageSet(new JWOxygenIconSet()) .build() ); - final var file = fileChooser.showAndWait(); - if (file.isEmpty()) { - return; + final var r = chooser.showAndWait(); + if (!r.isEmpty()) { + this.directoryField.setText(r.get(0).toAbsolutePath().toString()); } - - this.dialogs.directoryProperty() - .set(file.getFirst()); } @FXML - private void onCancel() + private void onExportSelected() { - this.result = Optional.empty(); - this.stage.close(); + final var outputDirectory = + Paths.get(this.directoryField.getText()); + final var exportImageFlag = + this.exportImages.isSelected(); + + this.running.set(true); + this.validate(); + + this.fileModelNow() + .export(new LExportRequest(outputDirectory, exportImageFlag)); } @FXML - private void onExport() + private void onCancelSelected() { this.stage.close(); } + + /** + * Open a new view for the given stage. + * + * @param services The service directory + * @param fileScope The file scope + * @param stage The stage + * + * @return A view and stage + * + * @throws Exception On errors + */ + + public static LViewAndStage openForStage( + final RPServiceDirectoryType services, + final LFileModelScope fileScope, + final Stage stage) + throws Exception + { + final var strings = + services.requireService(LStrings.class); + + final var xml = + LFileView.class.getResource( + "/com/io7m/laurel/gui/internal/exporter.fxml" + ); + final var resources = + strings.resources(); + final var loader = + new FXMLLoader(xml, resources); + + final LViewControllerFactoryType controllers = + LViewControllerFactoryMapped.create( + Map.entry( + LExporterView.class, + () -> { + return new LExporterView(services, fileScope, stage); + } + ) + ); + + loader.setControllerFactory(param -> { + return controllers.call((Class) param); + }); + + final var pane = loader.load(); + LCSS.setCSS(pane); + stage.setScene(new Scene(pane)); + stage.setTitle(strings.format(TITLE)); + stage.setWidth(600.0); + stage.setHeight(400.0); + + return new LViewAndStage<>(loader.getController(), stage); + } } diff --git a/com.io7m.laurel.gui/src/main/java/com/io7m/laurel/gui/internal/LFileView.java b/com.io7m.laurel.gui/src/main/java/com/io7m/laurel/gui/internal/LFileView.java index 4e73b68..961b3d6 100644 --- a/com.io7m.laurel.gui/src/main/java/com/io7m/laurel/gui/internal/LFileView.java +++ b/com.io7m.laurel.gui/src/main/java/com/io7m/laurel/gui/internal/LFileView.java @@ -61,7 +61,6 @@ public final class LFileView extends LAbstractViewWithModel private final LStrings strings; private final LFileChoosersType choosers; private final Stage stage; - private final LExporterDialogs exporterDialogs; private final LPreferencesType preferences; private final RPServiceDirectoryType services; @@ -100,8 +99,6 @@ private LFileView( this.services.requireService(LStrings.class); this.choosers = this.services.requireService(LFileChoosersType.class); - this.exporterDialogs = - this.services.requireService(LExporterDialogs.class); this.preferences = this.services.requireService(LPreferencesType.class); @@ -203,6 +200,7 @@ protected void onInitialize() this.menuItemClose.setDisable(true); this.menuItemRedo.setDisable(true); this.menuItemUndo.setDisable(true); + this.menuItemExport.setDisable(true); } @Override @@ -212,6 +210,7 @@ protected void onFileBecameUnavailable() this.mainContent.setDisable(true); this.mainContent.setVisible(false); this.menuItemClose.setDisable(true); + this.menuItemExport.setDisable(true); }); } @@ -249,6 +248,7 @@ protected void onFileBecameAvailable( this.mainContent.setDisable(false); this.mainContent.setVisible(true); this.menuItemClose.setDisable(false); + this.menuItemExport.setDisable(false); }); } @@ -434,12 +434,22 @@ public void onExitSelected() /** * The user tried to export. + * + * @throws Exception On errors */ @FXML public void onExportSelected() + throws Exception { + final var p = + LExporterView.openForStage( + this.services, + this.fileModelScope(), + new Stage() + ); + p.stage().showAndWait(); } /** diff --git a/com.io7m.laurel.gui/src/main/java/com/io7m/laurel/gui/internal/LImageView.java b/com.io7m.laurel.gui/src/main/java/com/io7m/laurel/gui/internal/LImageView.java index a56cb18..232293d 100644 --- a/com.io7m.laurel.gui/src/main/java/com/io7m/laurel/gui/internal/LImageView.java +++ b/com.io7m.laurel.gui/src/main/java/com/io7m/laurel/gui/internal/LImageView.java @@ -146,7 +146,7 @@ public static LImageView create( { try { final var layout = - LExporterDialogs.class.getResource( + LImageView.class.getResource( "/com/io7m/laurel/gui/internal/image.fxml"); Objects.requireNonNull(layout, "layout"); diff --git a/com.io7m.laurel.gui/src/main/resources/com/io7m/laurel/gui/internal/exporter.fxml b/com.io7m.laurel.gui/src/main/resources/com/io7m/laurel/gui/internal/exporter.fxml index 0c3e1ef..e034204 100644 --- a/com.io7m.laurel.gui/src/main/resources/com/io7m/laurel/gui/internal/exporter.fxml +++ b/com.io7m.laurel.gui/src/main/resources/com/io7m/laurel/gui/internal/exporter.fxml @@ -1,40 +1,86 @@ + + + + + + + + + + - + - + - + - - - + - + + + + + + + + + + + + + + + + + +