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 extends LViewType>) 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 @@
+
+
+
+
+
+
+
+
+
+
-
+
-
+
-
+
-
-
-
+
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
diff --git a/com.io7m.laurel.model/pom.xml b/com.io7m.laurel.model/pom.xml
index 80199ae..f2fd409 100644
--- a/com.io7m.laurel.model/pom.xml
+++ b/com.io7m.laurel.model/pom.xml
@@ -23,6 +23,10 @@
com.io7m.seltzer
com.io7m.seltzer.api
+
+ com.io7m.mime2045
+ com.io7m.mime2045.core
+
org.osgi
diff --git a/com.io7m.laurel.model/src/main/java/com/io7m/laurel/model/LImage.java b/com.io7m.laurel.model/src/main/java/com/io7m/laurel/model/LImage.java
index e6353b8..c3f7621 100644
--- a/com.io7m.laurel.model/src/main/java/com/io7m/laurel/model/LImage.java
+++ b/com.io7m.laurel.model/src/main/java/com/io7m/laurel/model/LImage.java
@@ -17,6 +17,8 @@
package com.io7m.laurel.model;
+import com.io7m.mime2045.core.MimeType;
+
import java.net.URI;
import java.nio.file.Path;
import java.util.Objects;
@@ -28,6 +30,7 @@
* @param name The name
* @param file The file
* @param source The source
+ * @param type The MIME type
* @param hash The hash
*/
@@ -35,6 +38,7 @@ public record LImage(
String name,
Optional file,
Optional source,
+ MimeType type,
LHashType hash)
{
/**
@@ -43,6 +47,7 @@ public record LImage(
* @param name The name
* @param file The file
* @param source The source
+ * @param type The MIME type
* @param hash The hash
*/
@@ -52,5 +57,6 @@ public record LImage(
Objects.requireNonNull(file, "file");
Objects.requireNonNull(source, "source");
Objects.requireNonNull(hash, "hash");
+ Objects.requireNonNull(type, "type");
}
}
diff --git a/com.io7m.laurel.model/src/main/java/module-info.java b/com.io7m.laurel.model/src/main/java/module-info.java
index 7fe9a3c..db122e1 100644
--- a/com.io7m.laurel.model/src/main/java/module-info.java
+++ b/com.io7m.laurel.model/src/main/java/module-info.java
@@ -24,6 +24,7 @@
requires static org.osgi.annotation.versioning;
requires com.io7m.seltzer.api;
+ requires com.io7m.mime2045.core;
exports com.io7m.laurel.model;
}
diff --git a/com.io7m.laurel.tests/src/main/java/com/io7m/laurel/tests/LFileModelExportTest.java b/com.io7m.laurel.tests/src/main/java/com/io7m/laurel/tests/LFileModelExportTest.java
new file mode 100644
index 0000000..06ce3bb
--- /dev/null
+++ b/com.io7m.laurel.tests/src/main/java/com/io7m/laurel/tests/LFileModelExportTest.java
@@ -0,0 +1,249 @@
+/*
+ * 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.tests;
+
+import com.io7m.laurel.filemodel.LExportRequest;
+import com.io7m.laurel.filemodel.LFileModelEventType;
+import com.io7m.laurel.filemodel.LFileModels;
+import com.io7m.laurel.filemodel.internal.LCaptionFiles;
+import com.io7m.laurel.gui.internal.LPerpetualSubscriber;
+import com.io7m.laurel.model.LCaptionName;
+import com.io7m.zelador.test_extension.CloseableResourcesType;
+import com.io7m.zelador.test_extension.ZeladorExtension;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.jupiter.api.io.TempDir;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.OpenOption;
+import java.nio.file.Path;
+import java.nio.file.StandardOpenOption;
+import java.util.HashMap;
+import java.util.List;
+import java.util.concurrent.ConcurrentLinkedQueue;
+import java.util.concurrent.TimeUnit;
+import java.util.zip.ZipInputStream;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+@ExtendWith({ZeladorExtension.class})
+public final class LFileModelExportTest
+{
+ private static final Logger LOG =
+ LoggerFactory.getLogger(LFileModelTest.class);
+
+ private static final long TIMEOUT = 10L;
+
+ private static final OpenOption[] OPEN_OPTIONS = {
+ StandardOpenOption.WRITE,
+ StandardOpenOption.CREATE,
+ StandardOpenOption.TRUNCATE_EXISTING
+ };
+
+ private Path directory;
+ private Path outputFile;
+ private CloseableResourcesType resources;
+ private ConcurrentLinkedQueue events;
+
+ @BeforeEach
+ public void setup(
+ final @TempDir Path directory,
+ final CloseableResourcesType resources)
+ {
+ this.directory = directory;
+ this.outputFile = directory.resolve("out.db");
+ this.resources = resources;
+ this.events = new ConcurrentLinkedQueue<>();
+ }
+
+ @AfterEach
+ public void tearDown()
+ {
+
+ }
+
+ @Test
+ public void testExportDatasetGood()
+ throws Exception
+ {
+ final var outputPath =
+ this.directory.resolve("export");
+ final var inputPath =
+ this.unpack("dataset_good.zip", "x");
+
+ try (var importer = LFileModels.createImport(inputPath, this.outputFile)) {
+ importer.events().subscribe(new LPerpetualSubscriber<>(this::addEvent));
+ importer.execute().get(1L, TimeUnit.MINUTES);
+ }
+
+ try (var model = LFileModels.open(this.outputFile, false)) {
+ Thread.sleep(1_000L);
+
+ model.export(
+ new LExportRequest(outputPath, true))
+ .get(1L, TimeUnit.MINUTES);
+
+ final var files = Files.list(outputPath).toList();
+ assertTrue(Files.isRegularFile(outputPath.resolve(
+ "00000000000000000001.png")));
+ assertTrue(Files.isRegularFile(outputPath.resolve(
+ "00000000000000000002.png")));
+ assertTrue(Files.isRegularFile(outputPath.resolve(
+ "00000000000000000003.png")));
+ assertTrue(Files.isRegularFile(outputPath.resolve(
+ "00000000000000000004.png")));
+ assertTrue(Files.isRegularFile(outputPath.resolve(
+ "00000000000000000005.png")));
+
+ final var cap1 = outputPath.resolve("00000000000000000001.caption");
+ final var cap2 = outputPath.resolve("00000000000000000002.caption");
+ final var cap3 = outputPath.resolve("00000000000000000003.caption");
+ final var cap4 = outputPath.resolve("00000000000000000004.caption");
+ final var cap5 = outputPath.resolve("00000000000000000005.caption");
+ assertTrue(Files.isRegularFile(cap1));
+ assertTrue(Files.isRegularFile(cap2));
+ assertTrue(Files.isRegularFile(cap3));
+ assertTrue(Files.isRegularFile(cap4));
+
+ assertTrue(Files.isRegularFile(cap5));
+ assertEquals(10, files.size());
+
+ final var m = new HashMap();
+
+ assertEquals(
+ List.of(
+ new LCaptionName("1boy"),
+ new LCaptionName("hat"),
+ new LCaptionName("horse"),
+ new LCaptionName("jewelry"),
+ new LCaptionName("male focus"),
+ new LCaptionName("outdoors"),
+ new LCaptionName("pants"),
+ new LCaptionName("photo background"),
+ new LCaptionName("shirt"),
+ new LCaptionName("solo"),
+ new LCaptionName("tree"),
+ new LCaptionName("white shirt")
+ ),
+ LCaptionFiles.parse(m, cap1)
+ );
+ assertEquals(
+ List.of(
+ new LCaptionName("bicycle"),
+ new LCaptionName("cloud"),
+ new LCaptionName("day"),
+ new LCaptionName("grass"),
+ new LCaptionName("ground vehicle"),
+ new LCaptionName("motor vehicle"),
+ new LCaptionName("motorcycle"),
+ new LCaptionName("no humans"),
+ new LCaptionName("outdoors"),
+ new LCaptionName("sky"),
+ new LCaptionName("traditional media"),
+ new LCaptionName("tree")
+ ),
+ LCaptionFiles.parse(m, cap2)
+ );
+ assertEquals(
+ List.of(
+ new LCaptionName("day"),
+ new LCaptionName("food"),
+ new LCaptionName("fruit"),
+ new LCaptionName("no humans"),
+ new LCaptionName("outdoors"),
+ new LCaptionName("sky"),
+ new LCaptionName("traditional media")
+ ),
+ LCaptionFiles.parse(m, cap3)
+ );
+ assertEquals(
+ List.of(
+ new LCaptionName("building"),
+ new LCaptionName("car"),
+ new LCaptionName("greyscale"),
+ new LCaptionName("ground vehicle"),
+ new LCaptionName("monochrome"),
+ new LCaptionName("motor vehicle"),
+ new LCaptionName("no humans"),
+ new LCaptionName("real world location"),
+ new LCaptionName("scenery"),
+ new LCaptionName("window")
+ ),
+ LCaptionFiles.parse(m, cap4)
+ );
+ assertEquals(
+ List.of(
+ new LCaptionName("animal"),
+ new LCaptionName("cat"),
+ new LCaptionName("grass"),
+ new LCaptionName("outdoors"),
+ new LCaptionName("oversized animal")
+ ),
+ LCaptionFiles.parse(m, cap5)
+ );
+ }
+ }
+
+ private void addEvent(
+ final LFileModelEventType e)
+ {
+ LOG.debug("Event: {}", e);
+ this.events.add(e);
+ }
+
+ private Path unpack(
+ final String zipName,
+ final String outputName)
+ throws IOException
+ {
+ final var outputDirectory =
+ this.directory.resolve(outputName);
+
+ Files.createDirectories(outputDirectory);
+
+ final var zipPath =
+ "/com/io7m/laurel/tests/%s".formatted(zipName);
+
+ try (var zipStream =
+ LFileModelExportTest.class.getResourceAsStream(zipPath)) {
+
+ try (var zipInputStream = new ZipInputStream(zipStream)) {
+ while (true) {
+ final var entry = zipInputStream.getNextEntry();
+ if (entry == null) {
+ break;
+ }
+
+ final var outputFile =
+ outputDirectory.resolve(entry.getName());
+
+ LOG.debug("Copy {} -> {}", entry.getName(), outputFile);
+ Files.copy(zipInputStream, outputFile);
+ }
+ }
+ }
+
+ return outputDirectory;
+ }
+}
diff --git a/pom.xml b/pom.xml
index 0061666..7dccc93 100644
--- a/pom.xml
+++ b/pom.xml
@@ -37,12 +37,14 @@
1.0.0
+ 1.0.3
2.1.0
+ 4.0.0
2.1.0
+ 1.1.0
1.1.0
1.8.0
1.0.0
- 4.0.0
1.42.1
@@ -50,7 +52,6 @@
3.19.13
5.11.1
3.46.0.1
- 1.0.3
@@ -298,6 +299,26 @@
tika-core
2.9.2
+
+ com.io7m.mime2045
+ com.io7m.mime2045.core
+ ${com.io7m.mime2045.version}
+
+
+ com.io7m.mime2045
+ com.io7m.mime2045.parser
+ ${com.io7m.mime2045.version}
+
+
+ com.io7m.mime2045
+ com.io7m.mime2045.parser.api
+ ${com.io7m.mime2045.version}
+
+
+ com.io7m.mime2045
+ com.io7m.mime2045.fileext
+ ${com.io7m.mime2045.version}
+
org.junit