diff --git a/com.io7m.laurel.cmdline/pom.xml b/com.io7m.laurel.cmdline/pom.xml
new file mode 100644
index 0000000..9708473
--- /dev/null
+++ b/com.io7m.laurel.cmdline/pom.xml
@@ -0,0 +1,142 @@
+
+
+
+
+ 4.0.0
+
+ com.io7m.laurel
+ com.io7m.laurel
+ 1.0.0-SNAPSHOT
+
+ com.io7m.laurel.cmdline
+
+ jar
+ com.io7m.laurel.cmdline
+ Image caption management (Command-line tools)
+ https://www.io7m.com/software/laurel/
+
+
+
+ ${project.groupId}
+ com.io7m.laurel.filemodel
+ ${project.version}
+
+
+ ${project.groupId}
+ com.io7m.laurel.model
+ ${project.version}
+
+
+
+ com.io7m.quarrel
+ com.io7m.quarrel.core
+
+
+ com.io7m.quarrel
+ com.io7m.quarrel.ext.logback
+
+
+ com.io7m.quarrel
+ com.io7m.quarrel.ext.xstructural
+
+
+ com.io7m.seltzer
+ com.io7m.seltzer.api
+
+
+ com.io7m.jattribute
+ com.io7m.jattribute.core
+
+
+
+ ch.qos.logback
+ logback-classic
+
+
+ org.slf4j
+ slf4j-api
+
+
+
+ org.osgi
+ org.osgi.annotation.bundle
+ provided
+
+
+ org.osgi
+ org.osgi.annotation.versioning
+ provided
+
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-dependency-plugin
+
+ true
+
+ com.io7m.quarrel:com.io7m.quarrel.ext.xstructural::*
+ ch.qos.logback:logback-classic::*
+
+
+
+
+
+ com.io7m.stmp
+ string-template-maven-plugin
+
+
+ generate-version
+ generate-sources
+
+ renderTemplate
+
+
+
+ src/main/string-template/LCVersion.st
+ LCVersion
+
+ ${project.build.directory}/generated-sources/string-template/com/io7m/laurel/cmdline/LCVersion.java
+
+
+ ${project.version}
+ ${buildNumber}
+
+
+
+
+
+
+
+
+ org.codehaus.mojo
+ build-helper-maven-plugin
+
+
+ add-sources
+
+ add-source
+
+
+ generate-sources
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/com.io7m.laurel.cmdline/src/main/java/com/io7m/laurel/cmdline/LCMain.java b/com.io7m.laurel.cmdline/src/main/java/com/io7m/laurel/cmdline/LCMain.java
new file mode 100644
index 0000000..57a882a
--- /dev/null
+++ b/com.io7m.laurel.cmdline/src/main/java/com/io7m/laurel/cmdline/LCMain.java
@@ -0,0 +1,128 @@
+/*
+ * 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.cmdline;
+
+import com.io7m.laurel.cmdline.internal.LCExport;
+import com.io7m.laurel.cmdline.internal.LCImport;
+import com.io7m.quarrel.core.QApplication;
+import com.io7m.quarrel.core.QApplicationMetadata;
+import com.io7m.quarrel.core.QApplicationType;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.net.URI;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+
+/**
+ * The command-line program.
+ */
+
+public final class LCMain implements Runnable
+{
+ private static final Logger LOG =
+ LoggerFactory.getLogger(LCMain.class);
+
+ private final List args;
+ private final QApplicationType application;
+ private int exitCode;
+
+ /**
+ * The main entry point.
+ *
+ * @param inArgs Command-line arguments
+ */
+
+ public LCMain(
+ final String[] inArgs)
+ {
+ this.args =
+ Objects.requireNonNull(List.of(inArgs), "Command line arguments");
+
+ final var metadata =
+ new QApplicationMetadata(
+ "laurel",
+ "com.io7m.laurel",
+ LCVersion.MAIN_VERSION,
+ LCVersion.MAIN_BUILD,
+ "The laurel command-line application.",
+ Optional.of(URI.create("https://www.io7m.com/software/laurel/"))
+ );
+
+ final var builder = QApplication.builder(metadata);
+ builder.allowAtSyntax(true);
+ builder.addCommand(new LCImport());
+ builder.addCommand(new LCExport());
+
+ this.application = builder.build();
+ this.exitCode = 0;
+ }
+
+ /**
+ * The main entry point.
+ *
+ * @param args Command line arguments
+ */
+
+ public static void main(
+ final String[] args)
+ {
+ System.exit(mainExitless(args));
+ }
+
+ /**
+ * The main (exitless) entry point.
+ *
+ * @param args Command line arguments
+ *
+ * @return The exit code
+ */
+
+ public static int mainExitless(
+ final String[] args)
+ {
+ final LCMain cm = new LCMain(args);
+ cm.run();
+ return cm.exitCode();
+ }
+
+ /**
+ * @return The program exit code
+ */
+
+ public int exitCode()
+ {
+ return this.exitCode;
+ }
+
+ @Override
+ public void run()
+ {
+ this.exitCode = this.application.run(LOG, this.args).exitCode();
+ }
+
+ @Override
+ public String toString()
+ {
+ return String.format(
+ "[LCMain 0x%s]",
+ Long.toUnsignedString(System.identityHashCode(this), 16)
+ );
+ }
+}
diff --git a/com.io7m.laurel.cmdline/src/main/java/com/io7m/laurel/cmdline/internal/LCExport.java b/com.io7m.laurel.cmdline/src/main/java/com/io7m/laurel/cmdline/internal/LCExport.java
new file mode 100644
index 0000000..feaab82
--- /dev/null
+++ b/com.io7m.laurel.cmdline/src/main/java/com/io7m/laurel/cmdline/internal/LCExport.java
@@ -0,0 +1,220 @@
+/*
+ * 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.cmdline.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.filemodel.LFileModelStatusIdle;
+import com.io7m.laurel.filemodel.LFileModelStatusLoading;
+import com.io7m.laurel.filemodel.LFileModels;
+import com.io7m.laurel.model.LException;
+import com.io7m.quarrel.core.QCommandContextType;
+import com.io7m.quarrel.core.QCommandMetadata;
+import com.io7m.quarrel.core.QCommandStatus;
+import com.io7m.quarrel.core.QCommandType;
+import com.io7m.quarrel.core.QParameterNamed1;
+import com.io7m.quarrel.core.QParameterNamedType;
+import com.io7m.quarrel.core.QStringType;
+import com.io7m.quarrel.ext.logback.QLogback;
+import com.io7m.seltzer.api.SStructuredErrorType;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.nio.file.Path;
+import java.util.List;
+import java.util.Optional;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Flow;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.stream.Stream;
+
+/**
+ * "export"
+ */
+
+public final class LCExport implements QCommandType,
+ Flow.Subscriber
+{
+ private static final Logger LOG =
+ LoggerFactory.getLogger(LCExport.class);
+
+ private static final QParameterNamed1 INPUT_FILE =
+ new QParameterNamed1<>(
+ "--input-file",
+ List.of(),
+ new QStringType.QConstant("The input file."),
+ Optional.empty(),
+ Path.class
+ );
+
+ private static final QParameterNamed1 OUTPUT_DIRECTORY =
+ new QParameterNamed1<>(
+ "--output-directory",
+ List.of(),
+ new QStringType.QConstant("The output directory."),
+ Optional.empty(),
+ Path.class
+ );
+
+ private static final QParameterNamed1 EXPORT_IMAGES =
+ new QParameterNamed1<>(
+ "--export-images",
+ List.of(),
+ new QStringType.QConstant("Whether to export images."),
+ Optional.of(Boolean.TRUE),
+ Boolean.class
+ );
+
+ private final QCommandMetadata metadata;
+ private final AtomicBoolean failed;
+ private QCommandContextType context;
+
+ /**
+ * Construct a command.
+ */
+
+ public LCExport()
+ {
+ this.metadata = new QCommandMetadata(
+ "export",
+ new QStringType.QConstant("Export a dataset into a directory."),
+ Optional.empty()
+ );
+
+ this.failed = new AtomicBoolean(false);
+ }
+
+ @Override
+ public List> onListNamedParameters()
+ {
+ return Stream.concat(
+ Stream.of(INPUT_FILE, OUTPUT_DIRECTORY, EXPORT_IMAGES),
+ QLogback.parameters().stream()
+ ).toList();
+ }
+
+ @Override
+ public QCommandStatus onExecute(
+ final QCommandContextType newContext)
+ {
+ System.setProperty("org.jooq.no-tips", "true");
+ System.setProperty("org.jooq.no-logo", "true");
+
+ this.context = newContext;
+ QLogback.configure(this.context);
+
+ final var inputFile =
+ this.context.parameterValue(INPUT_FILE);
+ final var outputDirectory =
+ this.context.parameterValue(OUTPUT_DIRECTORY);
+
+ try {
+ try (var model = LFileModels.open(inputFile, true)) {
+ model.events().subscribe(this);
+
+ LOG.info("Waiting for dataset to finish loading...");
+ final var loadLatch = new CountDownLatch(1);
+ model.status().subscribe((oldValue, newValue) -> {
+ if (oldValue instanceof LFileModelStatusLoading
+ && newValue instanceof LFileModelStatusIdle) {
+ loadLatch.countDown();
+ }
+ });
+ loadLatch.await();
+
+ LOG.info("Exporting dataset...");
+ model.export(new LExportRequest(
+ outputDirectory,
+ this.context.parameterValue(EXPORT_IMAGES)
+ .booleanValue()
+ )).get();
+
+ LOG.info("Export completed.");
+ return QCommandStatus.SUCCESS;
+ }
+ } catch (final LException e) {
+ logStructuredError(e);
+ } catch (final InterruptedException e) {
+ LOG.info("Interrupted");
+ } catch (final ExecutionException e) {
+ final var cause = e.getCause();
+ if (cause instanceof final SStructuredErrorType> s) {
+ logStructuredError(s);
+ } else {
+ LOG.error("Exception: ", e);
+ }
+ }
+ return QCommandStatus.FAILURE;
+ }
+
+ @Override
+ public QCommandMetadata metadata()
+ {
+ return this.metadata;
+ }
+
+ @Override
+ public void onSubscribe(
+ final Flow.Subscription subscription)
+ {
+ subscription.request(Long.MAX_VALUE);
+ }
+
+ @Override
+ public void onNext(
+ final LFileModelEventType item)
+ {
+ switch (item) {
+ case final LFileModelEvent event -> {
+ LOG.info("{}", event.message());
+ }
+ case final LFileModelEventError error -> {
+ this.failed.set(true);
+ logStructuredError(error);
+ }
+ }
+ }
+
+ private static void logStructuredError(
+ final SStructuredErrorType> error)
+ {
+ LOG.error("{}: {}", error.errorCode(), error.message());
+ for (final var entry : error.attributes().entrySet()) {
+ LOG.error(" {}: {}", entry.getKey(), entry.getValue());
+ }
+ error.exception()
+ .ifPresent(throwable -> LOG.error(" Exception: ", throwable));
+ }
+
+ @Override
+ public void onError(
+ final Throwable throwable)
+ {
+ LOG.error("Exception: ", throwable);
+ this.failed.set(true);
+ }
+
+ @Override
+ public void onComplete()
+ {
+
+ }
+}
diff --git a/com.io7m.laurel.cmdline/src/main/java/com/io7m/laurel/cmdline/internal/LCImport.java b/com.io7m.laurel.cmdline/src/main/java/com/io7m/laurel/cmdline/internal/LCImport.java
new file mode 100644
index 0000000..569ce59
--- /dev/null
+++ b/com.io7m.laurel.cmdline/src/main/java/com/io7m/laurel/cmdline/internal/LCImport.java
@@ -0,0 +1,189 @@
+/*
+ * 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.cmdline.internal;
+
+import com.io7m.laurel.filemodel.LFileModelEvent;
+import com.io7m.laurel.filemodel.LFileModelEventError;
+import com.io7m.laurel.filemodel.LFileModelEventType;
+import com.io7m.laurel.filemodel.LFileModels;
+import com.io7m.quarrel.core.QCommandContextType;
+import com.io7m.quarrel.core.QCommandMetadata;
+import com.io7m.quarrel.core.QCommandStatus;
+import com.io7m.quarrel.core.QCommandType;
+import com.io7m.quarrel.core.QParameterNamed1;
+import com.io7m.quarrel.core.QParameterNamedType;
+import com.io7m.quarrel.core.QStringType;
+import com.io7m.quarrel.ext.logback.QLogback;
+import com.io7m.seltzer.api.SStructuredErrorType;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.nio.file.Path;
+import java.util.List;
+import java.util.Optional;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Flow;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.stream.Stream;
+
+/**
+ * "import"
+ */
+
+public final class LCImport implements QCommandType, Flow.Subscriber
+{
+ private static final Logger LOG =
+ LoggerFactory.getLogger(LCImport.class);
+
+ private static final QParameterNamed1 INPUT_DIRECTORY =
+ new QParameterNamed1<>(
+ "--input-directory",
+ List.of(),
+ new QStringType.QConstant("The input directory."),
+ Optional.empty(),
+ Path.class
+ );
+
+ private static final QParameterNamed1 OUTPUT_FILE =
+ new QParameterNamed1<>(
+ "--output-file",
+ List.of(),
+ new QStringType.QConstant("The output file."),
+ Optional.empty(),
+ Path.class
+ );
+
+ private final QCommandMetadata metadata;
+ private final AtomicBoolean failed;
+ private QCommandContextType context;
+
+ /**
+ * Construct a command.
+ */
+
+ public LCImport()
+ {
+ this.metadata = new QCommandMetadata(
+ "import",
+ new QStringType.QConstant("Import a directory into a dataset."),
+ Optional.empty()
+ );
+
+ this.failed = new AtomicBoolean(false);
+ }
+
+ @Override
+ public List> onListNamedParameters()
+ {
+ return Stream.concat(
+ Stream.of(INPUT_DIRECTORY, OUTPUT_FILE),
+ QLogback.parameters().stream()
+ ).toList();
+ }
+
+ @Override
+ public QCommandStatus onExecute(
+ final QCommandContextType newContext)
+ {
+ System.setProperty("org.jooq.no-tips", "true");
+ System.setProperty("org.jooq.no-logo", "true");
+
+ this.context = newContext;
+ QLogback.configure(this.context);
+
+ final var inputDirectory =
+ this.context.parameterValue(INPUT_DIRECTORY);
+ final var outputFile =
+ this.context.parameterValue(OUTPUT_FILE);
+
+ try (var importer =
+ LFileModels.createImport(inputDirectory, outputFile)) {
+ importer.events().subscribe(this);
+ importer.execute().get();
+ } catch (final ExecutionException e) {
+ this.failed.set(true);
+ final var cause = e.getCause();
+ if (cause instanceof final SStructuredErrorType> s) {
+ logStructuredError(s);
+ } else {
+ LOG.error("Exception: ", e);
+ }
+ } catch (final InterruptedException e) {
+ this.failed.set(true);
+ LOG.info("Interrupted");
+ }
+
+ if (this.failed.get()) {
+ return QCommandStatus.FAILURE;
+ }
+ return QCommandStatus.SUCCESS;
+ }
+
+ @Override
+ public QCommandMetadata metadata()
+ {
+ return this.metadata;
+ }
+
+ @Override
+ public void onSubscribe(
+ final Flow.Subscription subscription)
+ {
+ subscription.request(Long.MAX_VALUE);
+ }
+
+ @Override
+ public void onNext(
+ final LFileModelEventType item)
+ {
+ switch (item) {
+ case final LFileModelEvent event -> {
+ LOG.info("{}", event.message());
+ }
+ case final LFileModelEventError error -> {
+ this.failed.set(true);
+ logStructuredError(error);
+ }
+ }
+ }
+
+ private static void logStructuredError(
+ final SStructuredErrorType> error)
+ {
+ LOG.error("{}: {}", error.errorCode(), error.message());
+ for (final var entry : error.attributes().entrySet()) {
+ LOG.error(" {}: {}", entry.getKey(), entry.getValue());
+ }
+ error.exception()
+ .ifPresent(throwable -> LOG.error(" Exception: ", throwable));
+ }
+
+ @Override
+ public void onError(
+ final Throwable throwable)
+ {
+ LOG.error("Exception: ", throwable);
+ this.failed.set(true);
+ }
+
+ @Override
+ public void onComplete()
+ {
+
+ }
+}
diff --git a/com.io7m.laurel.cmdline/src/main/java/com/io7m/laurel/cmdline/internal/package-info.java b/com.io7m.laurel.cmdline/src/main/java/com/io7m/laurel/cmdline/internal/package-info.java
new file mode 100644
index 0000000..628ac04
--- /dev/null
+++ b/com.io7m.laurel.cmdline/src/main/java/com/io7m/laurel/cmdline/internal/package-info.java
@@ -0,0 +1,24 @@
+/*
+ * 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.
+ */
+
+/**
+ * Image caption management (Command-line tools [internals])
+ */
+
+@Version("1.0.0")
+package com.io7m.laurel.cmdline.internal;
+
+import org.osgi.annotation.versioning.Version;
diff --git a/com.io7m.laurel.cmdline/src/main/java/com/io7m/laurel/cmdline/package-info.java b/com.io7m.laurel.cmdline/src/main/java/com/io7m/laurel/cmdline/package-info.java
new file mode 100644
index 0000000..8ca71b7
--- /dev/null
+++ b/com.io7m.laurel.cmdline/src/main/java/com/io7m/laurel/cmdline/package-info.java
@@ -0,0 +1,26 @@
+/*
+ * 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.
+ */
+
+/**
+ * Image caption management (Command-line tools)
+ */
+
+@Export
+@Version("1.0.0")
+package com.io7m.laurel.cmdline;
+
+import org.osgi.annotation.bundle.Export;
+import org.osgi.annotation.versioning.Version;
diff --git a/com.io7m.laurel.cmdline/src/main/java/module-info.java b/com.io7m.laurel.cmdline/src/main/java/module-info.java
new file mode 100644
index 0000000..ece53d4
--- /dev/null
+++ b/com.io7m.laurel.cmdline/src/main/java/module-info.java
@@ -0,0 +1,33 @@
+/*
+ * 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.
+ */
+
+/**
+ * Image caption management (Command-line tools)
+ */
+
+module com.io7m.laurel.cmdline
+{
+ requires static org.osgi.annotation.bundle;
+ requires static org.osgi.annotation.versioning;
+
+ requires com.io7m.jattribute.core;
+ requires com.io7m.laurel.filemodel;
+ requires com.io7m.laurel.model;
+ requires com.io7m.quarrel.core;
+ requires com.io7m.quarrel.ext.logback;
+
+ exports com.io7m.laurel.cmdline;
+}
diff --git a/com.io7m.laurel.cmdline/src/main/resources/logback.xml b/com.io7m.laurel.cmdline/src/main/resources/logback.xml
new file mode 100644
index 0000000..6b9ec35
--- /dev/null
+++ b/com.io7m.laurel.cmdline/src/main/resources/logback.xml
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+ %level %logger: %msg%n
+
+ System.out
+
+
+
+
+
+
+
+
+
+
+
diff --git a/com.io7m.laurel.cmdline/src/main/resources/logback.xsd b/com.io7m.laurel.cmdline/src/main/resources/logback.xsd
new file mode 100644
index 0000000..16db5d6
--- /dev/null
+++ b/com.io7m.laurel.cmdline/src/main/resources/logback.xsd
@@ -0,0 +1,523 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/com.io7m.laurel.cmdline/src/main/string-template/LCVersion.st b/com.io7m.laurel.cmdline/src/main/string-template/LCVersion.st
new file mode 100644
index 0000000..00708f1
--- /dev/null
+++ b/com.io7m.laurel.cmdline/src/main/string-template/LCVersion.st
@@ -0,0 +1,34 @@
+LCVersion(
+ appVersion,
+ appBuild) ::= <<
+
+/*
+ * 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.cmdline;
+
+public final class LCVersion
+{
+ public static final String MAIN_VERSION = "";
+ public static final String MAIN_BUILD = "";
+
+ private LCVersion()
+ {
+
+ }
+}
+
+>>
diff --git a/com.io7m.laurel.tests/pom.xml b/com.io7m.laurel.tests/pom.xml
index 3cc0b69..154b93e 100644
--- a/com.io7m.laurel.tests/pom.xml
+++ b/com.io7m.laurel.tests/pom.xml
@@ -24,6 +24,11 @@
+
+ ${project.groupId}
+ com.io7m.laurel.cmdline
+ ${project.version}
+
${project.groupId}
com.io7m.laurel.model
diff --git a/com.io7m.laurel.tests/src/main/java/com/io7m/laurel/tests/LCommandLineTest.java b/com.io7m.laurel.tests/src/main/java/com/io7m/laurel/tests/LCommandLineTest.java
new file mode 100644
index 0000000..9fea9ff
--- /dev/null
+++ b/com.io7m.laurel.tests/src/main/java/com/io7m/laurel/tests/LCommandLineTest.java
@@ -0,0 +1,155 @@
+/*
+ * 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.cmdline.LCMain;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+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.Path;
+import java.util.zip.ZipInputStream;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+public final class LCommandLineTest
+{
+ private static final Logger LOG =
+ LoggerFactory.getLogger(LCommandLineTest.class);
+
+ private Path directory;
+
+ @BeforeEach
+ public void setup(
+ final @TempDir Path directory)
+ {
+ this.directory = directory;
+ }
+
+ @Test
+ public void testHelp()
+ {
+ LCMain.mainExitless(new String[]{
+ "help",
+ "help"
+ });
+ }
+
+ @Test
+ public void testImport()
+ throws IOException
+ {
+ final var inputDir =
+ this.unpack("dataset_good.zip", "import");
+ final var outputFile =
+ this.directory.resolve("output.db")
+ .toAbsolutePath();
+
+ final var r = LCMain.mainExitless(new String[]{
+ "import",
+ "--input-directory",
+ inputDir.toAbsolutePath().toString(),
+ "--output-file",
+ outputFile.toString()
+ });
+ assertEquals(0, r);
+
+ assertTrue(
+ Files.isRegularFile(outputFile),
+ () -> "Output file %s must be a regular file".formatted(outputFile)
+ );
+ }
+
+
+ @Test
+ public void testExport()
+ throws IOException
+ {
+ final var inputDir =
+ this.unpack("dataset_good.zip", "import");
+ final var outputFile =
+ this.directory.resolve("output.db")
+ .toAbsolutePath();
+ final var outputDirectory =
+ this.directory.resolve("export")
+ .toAbsolutePath();
+
+ var r = LCMain.mainExitless(new String[]{
+ "import",
+ "--input-directory",
+ inputDir.toAbsolutePath().toString(),
+ "--output-file",
+ outputFile.toString()
+ });
+ assertEquals(0, r);
+
+ r = LCMain.mainExitless(new String[]{
+ "export",
+ "--output-directory",
+ outputDirectory.toAbsolutePath().toString(),
+ "--input-file",
+ outputFile.toString()
+ });
+ assertEquals(0, r);
+
+ assertTrue(
+ Files.isDirectory(outputDirectory),
+ () -> "Output directory %s must be a directory".formatted(outputDirectory)
+ );
+ }
+
+ 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/com.io7m.laurel.tests/src/main/java/module-info.java b/com.io7m.laurel.tests/src/main/java/module-info.java
index be6a0bf..69c2157 100644
--- a/com.io7m.laurel.tests/src/main/java/module-info.java
+++ b/com.io7m.laurel.tests/src/main/java/module-info.java
@@ -25,6 +25,7 @@
requires com.io7m.laurel.model;
requires com.io7m.laurel.gui;
requires com.io7m.laurel.filemodel;
+ requires com.io7m.laurel.cmdline;
requires com.io7m.anethum.api;
requires com.io7m.jattribute.core;
diff --git a/pom.xml b/pom.xml
index bede335..9872015 100644
--- a/pom.xml
+++ b/pom.xml
@@ -21,6 +21,7 @@
https://www.io7m.com/software/laurel
+ com.io7m.laurel.cmdline
com.io7m.laurel.documentation
com.io7m.laurel.filemodel
com.io7m.laurel.gui.main
@@ -55,6 +56,7 @@
3.19.13
5.11.1
3.46.0.1
+ 1.6.1
@@ -132,6 +134,21 @@
${javafx.version}
+
+ com.io7m.quarrel
+ com.io7m.quarrel.core
+ ${com.io7m.quarrel.version}
+
+
+ com.io7m.quarrel
+ com.io7m.quarrel.ext.logback
+ ${com.io7m.quarrel.version}
+
+
+ com.io7m.quarrel
+ com.io7m.quarrel.ext.xstructural
+ ${com.io7m.quarrel.version}
+
com.io7m.jdeferthrow
com.io7m.jdeferthrow.core
@@ -414,6 +431,12 @@
+
+ com.io7m.stmp
+ string-template-maven-plugin
+ 2.0.0
+
+
com.io7m.jxtrand
com.io7m.jxtrand.maven_plugin