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 com.io7m.jattribute.core.AttributeReadableType;
+import com.io7m.laurel.model.LException;
+import com.io7m.laurel.model.LImage;
+import com.io7m.laurel.model.LTag;
+
+import java.net.URI;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.Optional;
+import java.util.concurrent.CompletableFuture;
+
+/**
+ * The interface to a file model.
+ */
+
+public interface LFileModelType
+ extends AutoCloseable
+{
+ /**
+ * Add a tag.
+ *
+ * @param text The tag
+ *
+ * @return The operation in progress
+ */
+
+ CompletableFuture> tagAdd(
+ LTag text);
+
+ /**
+ * Load an image and add it to the file.
+ *
+ * @param name The (unique) name
+ * @param file The file
+ * @param source The URI source, if any
+ *
+ * @return The operation in progress
+ */
+
+ CompletableFuture> imageAdd(
+ String name,
+ Path file,
+ Optional source
+ );
+
+ /**
+ * Select an image.
+ *
+ * @param name The name
+ *
+ * @return The operation in progress
+ */
+
+ CompletableFuture> imageSelect(
+ Optional name);
+
+ @Override
+ void close()
+ throws LException;
+
+ /**
+ * @return The currently selected image
+ */
+
+ AttributeReadableType> imageSelected();
+
+ /**
+ * @return The current complete list of images
+ */
+
+ AttributeReadableType> imageList();
+
+ /**
+ * @return The current complete list of tags
+ */
+
+ AttributeReadableType> tagList();
+
+ /**
+ * @return The list of tags assigned to the current image
+ */
+
+ AttributeReadableType> tagsAssigned();
+
+ /**
+ * @return Text describing the top of the undo stack, if any
+ */
+
+ AttributeReadableType> undoText();
+
+ /**
+ * Undo the operation on the top of the undo stack
+ *
+ * @return The operation in progress
+ */
+
+ CompletableFuture> undo();
+
+ /**
+ * Redo the operation on the top of the redo stack
+ *
+ * @return The operation in progress
+ */
+
+ CompletableFuture> redo();
+
+ /**
+ * @return Text describing the top of the redo stack, if any
+ */
+
+ AttributeReadableType> redoText();
+}
diff --git a/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/LFileModels.java b/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/LFileModels.java
new file mode 100644
index 0000000..140d668
--- /dev/null
+++ b/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/LFileModels.java
@@ -0,0 +1,54 @@
+/*
+ * 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 com.io7m.laurel.filemodel.internal.LFileModel;
+import com.io7m.laurel.model.LException;
+
+import java.nio.file.Path;
+
+/**
+ * The file models.
+ */
+
+public final class LFileModels
+{
+ private LFileModels()
+ {
+
+ }
+
+ /**
+ * Open a file model.
+ *
+ * @param file The file
+ * @param readOnly {@code true} if the file should be read-only
+ *
+ * @return A file model
+ *
+ * @throws LException On errors
+ */
+
+ public static LFileModelType open(
+ final Path file,
+ final boolean readOnly)
+ throws LException
+ {
+ return LFileModel.open(file, readOnly);
+ }
+}
diff --git a/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/internal/LCommandAbstract.java b/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/internal/LCommandAbstract.java
new file mode 100644
index 0000000..0763df4
--- /dev/null
+++ b/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/internal/LCommandAbstract.java
@@ -0,0 +1,122 @@
+/*
+ * 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.darco.api.DDatabaseException;
+import com.io7m.laurel.model.LException;
+
+import java.util.Objects;
+
+/**
+ * The abstract base of commands.
+ *
+ * @param The type of parameters
+ */
+
+public abstract class LCommandAbstract
+ implements LCommandType
+{
+ private boolean executed;
+
+ /**
+ * @return {@code true} if this command has ever successfully executed
+ */
+
+ public final boolean isExecuted()
+ {
+ return this.executed;
+ }
+
+ /**
+ * Set the command as executed.
+ *
+ * @param e The state
+ */
+
+ public final void setExecuted(
+ final boolean e)
+ {
+ this.executed = e;
+ }
+
+ protected abstract LCommandUndoable onExecute(
+ LFileModel model,
+ LDatabaseTransactionType transaction,
+ T parameters)
+ throws LException, DDatabaseException;
+
+ protected abstract void onRedo(
+ LFileModel model,
+ LDatabaseTransactionType transaction)
+ throws LException, DDatabaseException;
+
+ protected abstract void onUndo(
+ LFileModel model,
+ LDatabaseTransactionType transaction)
+ throws LException, DDatabaseException;
+
+ @Override
+ public final LCommandUndoable execute(
+ final LFileModel model,
+ final LDatabaseTransactionType transaction,
+ final T parameters)
+ throws LException, DDatabaseException
+ {
+ Objects.requireNonNull(model, "model");
+ Objects.requireNonNull(transaction, "transaction");
+ Objects.requireNonNull(parameters, "parameters");
+
+ final var r = this.onExecute(model, transaction, parameters);
+ this.setExecuted(true);
+ return r;
+ }
+
+ @Override
+ public final void redo(
+ final LFileModel model,
+ final LDatabaseTransactionType transaction)
+ throws LException, DDatabaseException
+ {
+ Objects.requireNonNull(model, "model");
+ Objects.requireNonNull(transaction, "transaction");
+
+ if (!this.isExecuted()) {
+ throw new IllegalStateException(
+ "Cannot redo a command that has not executed.");
+ }
+
+ this.onRedo(model, transaction);
+ }
+
+ @Override
+ public final void undo(
+ final LFileModel model,
+ final LDatabaseTransactionType transaction)
+ throws LException, DDatabaseException
+ {
+ Objects.requireNonNull(model, "model");
+ Objects.requireNonNull(transaction, "transaction");
+
+ if (!this.isExecuted()) {
+ throw new IllegalStateException(
+ "Cannot undo a command that has not executed.");
+ }
+
+ this.onUndo(model, transaction);
+ }
+}
diff --git a/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/internal/LCommandFactory.java b/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/internal/LCommandFactory.java
new file mode 100644
index 0000000..b0a5f54
--- /dev/null
+++ b/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/internal/LCommandFactory.java
@@ -0,0 +1,29 @@
+/*
+ * 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 java.util.Properties;
+import java.util.function.Function;
+
+record LCommandFactory(
+ String commandClass,
+ Function> constructor)
+ implements LCommandFactoryType
+{
+
+}
diff --git a/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/internal/LCommandFactoryType.java b/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/internal/LCommandFactoryType.java
new file mode 100644
index 0000000..e34bea0
--- /dev/null
+++ b/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/internal/LCommandFactoryType.java
@@ -0,0 +1,44 @@
+/*
+ * 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 java.util.Properties;
+import java.util.function.Function;
+
+/**
+ * A command factory.
+ *
+ * @param The type of parameters
+ */
+
+public interface LCommandFactoryType
+{
+ /**
+ * @return The canonical name of the command class
+ *
+ * @see Class#getCanonicalName()
+ */
+
+ String commandClass();
+
+ /**
+ * @return A function to create a command from a set of properties
+ */
+
+ Function> constructor();
+}
diff --git a/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/internal/LCommandImageAdd.java b/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/internal/LCommandImageAdd.java
new file mode 100644
index 0000000..8b9e7c3
--- /dev/null
+++ b/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/internal/LCommandImageAdd.java
@@ -0,0 +1,266 @@
+/*
+ * 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.LException;
+import com.io7m.laurel.model.LHashSHA256;
+import com.io7m.laurel.model.LImage;
+import org.jooq.DSLContext;
+
+import javax.imageio.ImageIO;
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.net.URI;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.HexFormat;
+import java.util.List;
+import java.util.Optional;
+import java.util.Properties;
+
+import static com.io7m.laurel.filemodel.internal.Tables.IMAGES;
+import static com.io7m.laurel.filemodel.internal.Tables.IMAGE_BLOBS;
+
+/**
+ * Add an image.
+ */
+
+public final class LCommandImageAdd
+ extends LCommandAbstract
+{
+ private Long savedBlobId;
+ private Long savedImageId;
+ private String savedSourceText;
+ private String savedFile;
+ private String savedName;
+
+ /**
+ * Add an image.
+ */
+
+ public LCommandImageAdd()
+ {
+
+ }
+
+ /**
+ * @return A command factory
+ */
+
+ public static LCommandFactoryType provider()
+ {
+ return new LCommandFactory<>(
+ LCommandImageAdd.class.getCanonicalName(),
+ LCommandImageAdd::fromProperties
+ );
+ }
+
+ private static LCommandImageAdd fromProperties(
+ final Properties p)
+ {
+ final var c = new LCommandImageAdd();
+ c.savedBlobId = Long.parseUnsignedLong(p.getProperty("blob"));
+ c.savedImageId = Long.parseUnsignedLong(p.getProperty("id"));
+ c.savedSourceText = p.getProperty("source");
+ c.savedFile = p.getProperty("file");
+ c.savedName = p.getProperty("name");
+ c.setExecuted(true);
+ return c;
+ }
+
+ @Override
+ protected LCommandUndoable onExecute(
+ final LFileModel model,
+ final LDatabaseTransactionType transaction,
+ final LImageRequest request)
+ throws LException
+ {
+ final var context =
+ transaction.get(DSLContext.class);
+
+ final var file = request.file().toAbsolutePath();
+ model.setAttribute("ImageFile", file);
+
+ final var imageBytes =
+ loadImage(model, request);
+ final var imageHash =
+ hashOf(imageBytes);
+
+ final var blobRec =
+ context.insertInto(IMAGE_BLOBS)
+ .set(IMAGE_BLOBS.IMAGE_BLOB_SHA256, imageHash.value())
+ .set(IMAGE_BLOBS.IMAGE_BLOB_DATA, imageBytes)
+ .returning(IMAGE_BLOBS.IMAGE_BLOB_ID)
+ .fetchOne();
+
+ this.savedName =
+ request.name();
+ this.savedBlobId =
+ blobRec.get(IMAGE_BLOBS.IMAGE_BLOB_ID);
+ this.savedFile =
+ file.toString();
+ this.savedSourceText =
+ request.source().map(URI::toString).orElse(null);
+
+ this.savedImageId =
+ context.insertInto(IMAGES)
+ .set(IMAGES.IMAGE_BLOB, this.savedBlobId)
+ .set(IMAGES.IMAGE_FILE, this.savedFile)
+ .set(IMAGES.IMAGE_SOURCE, this.savedSourceText)
+ .set(IMAGES.IMAGE_NAME, this.savedName)
+ .returning(IMAGES.IMAGE_ID)
+ .fetchOne()
+ .get(IMAGES.IMAGE_ID);
+
+ model.setImagesAll(listImages(transaction));
+ return LCommandUndoable.COMMAND_UNDOABLE;
+ }
+
+ private static List listImages(
+ final LDatabaseTransactionType transaction)
+ {
+ final var context =
+ transaction.get(DSLContext.class);
+
+ return context.select(
+ IMAGES.IMAGE_SOURCE,
+ IMAGES.IMAGE_NAME,
+ IMAGES.IMAGE_FILE,
+ IMAGE_BLOBS.IMAGE_BLOB_SHA256
+ )
+ .from(IMAGES)
+ .join(IMAGE_BLOBS)
+ .on(IMAGE_BLOBS.IMAGE_BLOB_ID.eq(IMAGES.IMAGE_BLOB))
+ .orderBy(IMAGES.IMAGE_NAME)
+ .stream()
+ .map(LCommandImageAdd::mapRecord)
+ .toList();
+ }
+
+ private static LImage mapRecord(
+ final org.jooq.Record r)
+ {
+ return 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))
+ );
+ }
+
+ private static LHashSHA256 hashOf(
+ final byte[] imageBytes)
+ {
+ final MessageDigest digest;
+ try {
+ digest = MessageDigest.getInstance("SHA-256");
+ } catch (final NoSuchAlgorithmException e) {
+ throw new IllegalStateException(e);
+ }
+
+ digest.update(imageBytes);
+ return new LHashSHA256(HexFormat.of().formatHex(digest.digest()));
+ }
+
+ private static byte[] loadImage(
+ final LFileModel model,
+ final LImageRequest request)
+ throws LException
+ {
+ try {
+ final var imageBytes =
+ Files.readAllBytes(request.file());
+
+ try (var imageStream = new ByteArrayInputStream(imageBytes)) {
+ final var image = ImageIO.read(imageStream);
+ if (image == null) {
+ throw new LException(
+ "Failed to load image.",
+ "error-image-format",
+ model.attributes(),
+ Optional.empty()
+ );
+ }
+ }
+
+ return imageBytes;
+ } catch (final IOException e) {
+ throw new LException(
+ "Failed to open image file.",
+ e,
+ "error-io",
+ model.attributes(),
+ Optional.empty()
+ );
+ }
+ }
+
+ @Override
+ protected void onUndo(
+ final LFileModel model,
+ final LDatabaseTransactionType transaction)
+ {
+ final var context =
+ transaction.get(DSLContext.class);
+
+ context.deleteFrom(IMAGES)
+ .where(IMAGES.IMAGE_ID.eq(this.savedImageId))
+ .execute();
+
+ model.setImagesAll(listImages(transaction));
+ }
+
+ @Override
+ protected void onRedo(
+ final LFileModel model,
+ final LDatabaseTransactionType transaction)
+ {
+ final var context =
+ transaction.get(DSLContext.class);
+
+ context.insertInto(IMAGES)
+ .set(IMAGES.IMAGE_ID, this.savedImageId)
+ .set(IMAGES.IMAGE_BLOB, this.savedBlobId)
+ .set(IMAGES.IMAGE_FILE, this.savedFile)
+ .set(IMAGES.IMAGE_SOURCE, this.savedSourceText)
+ .set(IMAGES.IMAGE_NAME, this.savedName)
+ .execute();
+
+ model.setImagesAll(listImages(transaction));
+ }
+
+ @Override
+ public Properties toProperties()
+ {
+ final var p = new Properties();
+ p.setProperty("id", Long.toUnsignedString(this.savedImageId));
+ p.setProperty("blob", Long.toUnsignedString(this.savedBlobId));
+ p.setProperty("file", this.savedFile);
+ p.setProperty("source", this.savedSourceText);
+ p.setProperty("name", this.savedName);
+ return p;
+ }
+
+ @Override
+ public String describe()
+ {
+ return "Add image '%s'".formatted(this.savedName);
+ }
+}
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
new file mode 100644
index 0000000..6432a34
--- /dev/null
+++ b/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/internal/LCommandImageSelect.java
@@ -0,0 +1,175 @@
+/*
+ * 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.LHashSHA256;
+import com.io7m.laurel.model.LImage;
+import com.io7m.laurel.model.LTag;
+import org.jooq.DSLContext;
+
+import java.net.URI;
+import java.nio.file.Paths;
+import java.util.List;
+import java.util.Optional;
+import java.util.Properties;
+
+import static com.io7m.laurel.filemodel.internal.Tables.IMAGES;
+import static com.io7m.laurel.filemodel.internal.Tables.IMAGE_BLOBS;
+import static com.io7m.laurel.filemodel.internal.Tables.IMAGE_TAGS;
+import static com.io7m.laurel.filemodel.internal.Tables.TAGS;
+
+/**
+ * Select an image.
+ */
+
+public final class LCommandImageSelect
+ extends LCommandAbstract>
+{
+ /**
+ * Select an image.
+ */
+
+ public LCommandImageSelect()
+ {
+
+ }
+
+ /**
+ * @return A command factory
+ */
+
+ public static LCommandFactoryType> provider()
+ {
+ return new LCommandFactory<>(
+ LCommandImageSelect.class.getCanonicalName(),
+ LCommandImageSelect::fromProperties
+ );
+ }
+
+ private static LCommandImageSelect fromProperties(
+ final Properties p)
+ {
+ final var c = new LCommandImageSelect();
+ c.setExecuted(true);
+ return c;
+ }
+
+ private static LTag mapRecord(
+ final org.jooq.Record r)
+ {
+ return new LTag(r.get(TAGS.TAG_TEXT));
+ }
+
+ @Override
+ protected LCommandUndoable onExecute(
+ final LFileModel model,
+ final LDatabaseTransactionType transaction,
+ final Optional request)
+ {
+ final var context =
+ transaction.get(DSLContext.class);
+
+ if (request.isEmpty()) {
+ model.setTagsAssigned(List.of());
+ model.setImageSelected(Optional.empty());
+ return LCommandUndoable.COMMAND_NOT_UNDOABLE;
+ }
+
+ final var imageName =
+ request.get();
+
+ final var imageRecOpt =
+ context.select(
+ IMAGES.IMAGE_BLOB,
+ IMAGES.IMAGE_FILE,
+ IMAGES.IMAGE_ID,
+ IMAGES.IMAGE_NAME,
+ IMAGES.IMAGE_SOURCE,
+ IMAGE_BLOBS.IMAGE_BLOB_SHA256
+ ).from(IMAGES)
+ .join(IMAGE_BLOBS)
+ .on(IMAGE_BLOBS.IMAGE_BLOB_ID.eq(IMAGES.IMAGE_ID))
+ .where(IMAGES.IMAGE_NAME.eq(imageName))
+ .fetchOptional();
+
+ if (imageRecOpt.isEmpty()) {
+ model.setTagsAssigned(List.of());
+ model.setImageSelected(Optional.empty());
+ return LCommandUndoable.COMMAND_NOT_UNDOABLE;
+ }
+
+ final var imageRec =
+ imageRecOpt.get();
+ final var imageId =
+ imageRec.get(IMAGES.IMAGE_ID);
+
+ final var tags =
+ context.select(TAGS.TAG_TEXT)
+ .from(TAGS)
+ .join(IMAGE_TAGS)
+ .on(IMAGE_TAGS.IMAGE_TAG_TAG.eq(TAGS.TAG_ID))
+ .where(IMAGE_TAGS.IMAGE_TAG_IMAGE.eq(imageId))
+ .orderBy(TAGS.TAG_TEXT.asc())
+ .stream()
+ .map(LCommandImageSelect::mapRecord)
+ .toList();
+
+ model.setTagsAssigned(tags);
+ model.setImageSelected(
+ Optional.of(
+ new LImage(
+ imageName,
+ 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))
+ )
+ )
+ );
+ return LCommandUndoable.COMMAND_NOT_UNDOABLE;
+ }
+
+ @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 "Select image";
+ }
+}
diff --git a/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/internal/LCommandTagAdd.java b/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/internal/LCommandTagAdd.java
new file mode 100644
index 0000000..9c1cb86
--- /dev/null
+++ b/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/internal/LCommandTagAdd.java
@@ -0,0 +1,160 @@
+/*
+ * 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.LTag;
+import org.jooq.DSLContext;
+
+import java.util.List;
+import java.util.Properties;
+
+import static com.io7m.laurel.filemodel.internal.Tables.TAGS;
+
+/**
+ * Add a tag.
+ */
+
+public final class LCommandTagAdd
+ extends LCommandAbstract
+{
+ private long id;
+ private String text;
+
+ /**
+ * Add a tag.
+ */
+
+ public LCommandTagAdd()
+ {
+
+ }
+
+ /**
+ * Add a tag.
+ *
+ * @return A command factory
+ */
+
+ public static LCommandFactoryType provider()
+ {
+ return new LCommandFactory<>(
+ LCommandTagAdd.class.getCanonicalName(),
+ LCommandTagAdd::fromProperties
+ );
+ }
+
+ private static LCommandTagAdd fromProperties(
+ final Properties p)
+ {
+ final var c = new LCommandTagAdd();
+ c.id = Long.parseUnsignedLong(p.getProperty("id"));
+ c.text = p.getProperty("text");
+ c.setExecuted(true);
+ return c;
+ }
+
+ private static List listTags(
+ final LDatabaseTransactionType transaction)
+ {
+ final var context =
+ transaction.get(DSLContext.class);
+
+ return context.select(TAGS.TAG_TEXT)
+ .from(TAGS)
+ .orderBy(TAGS.TAG_TEXT.asc())
+ .stream()
+ .map(r -> new LTag(r.get(TAGS.TAG_TEXT)))
+ .toList();
+ }
+
+ @Override
+ protected LCommandUndoable onExecute(
+ final LFileModel model,
+ final LDatabaseTransactionType transaction,
+ final LTag tag)
+ {
+ final var context =
+ transaction.get(DSLContext.class);
+
+ final var recOpt =
+ context.insertInto(TAGS)
+ .set(TAGS.TAG_TEXT, tag.text())
+ .onDuplicateKeyIgnore()
+ .returning(TAGS.TAG_ID)
+ .fetchOptional();
+
+ if (recOpt.isEmpty()) {
+ return LCommandUndoable.COMMAND_NOT_UNDOABLE;
+ }
+
+ final var rec = recOpt.get();
+ this.id = rec.get(TAGS.TAG_ID).longValue();
+ this.text = tag.text();
+
+ model.setTagsAll(listTags(transaction));
+ return LCommandUndoable.COMMAND_UNDOABLE;
+ }
+
+ @Override
+ protected void onUndo(
+ final LFileModel model,
+ final LDatabaseTransactionType transaction)
+ {
+ final var context =
+ transaction.get(DSLContext.class);
+
+ context.deleteFrom(TAGS)
+ .where(TAGS.TAG_ID.eq(this.id))
+ .execute();
+
+ model.setTagsAll(listTags(transaction));
+ }
+
+ @Override
+ protected void onRedo(
+ final LFileModel model,
+ final LDatabaseTransactionType transaction)
+ {
+ final var context =
+ transaction.get(DSLContext.class);
+
+ context.insertInto(TAGS)
+ .set(TAGS.TAG_ID, this.id)
+ .set(TAGS.TAG_TEXT, this.text)
+ .onDuplicateKeyUpdate()
+ .set(TAGS.TAG_TEXT, this.text)
+ .execute();
+
+ model.setTagsAll(listTags(transaction));
+ }
+
+ @Override
+ public Properties toProperties()
+ {
+ final var properties = new Properties();
+ properties.setProperty("id", Long.toUnsignedString(this.id));
+ properties.setProperty("text", this.text);
+ return properties;
+ }
+
+ @Override
+ public String describe()
+ {
+ return "Add tag '%s'".formatted(this.text);
+ }
+}
diff --git a/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/internal/LCommandType.java b/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/internal/LCommandType.java
new file mode 100644
index 0000000..f3c4b8c
--- /dev/null
+++ b/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/internal/LCommandType.java
@@ -0,0 +1,116 @@
+/*
+ * 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.darco.api.DDatabaseException;
+import com.io7m.laurel.model.LException;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.nio.charset.StandardCharsets;
+import java.util.Properties;
+
+/**
+ * An internal command.
+ *
+ * @param The type of parameters
+ */
+
+public interface LCommandType
+{
+ /**
+ * Execute the command.
+ *
+ * @param model The model
+ * @param transaction The database transaction
+ * @param parameters The parameters
+ *
+ * @return A value indicating if the command can be undone
+ *
+ * @throws LException On errors
+ * @throws DDatabaseException On errors
+ */
+
+ LCommandUndoable execute(
+ LFileModel model,
+ LDatabaseTransactionType transaction,
+ P parameters)
+ throws LException, DDatabaseException;
+
+ /**
+ * Undo the command.
+ *
+ * @param model The model
+ * @param transaction The database transaction
+ *
+ * @throws LException On errors
+ * @throws DDatabaseException On errors
+ */
+
+ void undo(
+ LFileModel model,
+ LDatabaseTransactionType transaction)
+ throws LException, DDatabaseException;
+
+ /**
+ * Redo the command.
+ *
+ * @param model The model
+ * @param transaction The database transaction
+ *
+ * @throws LException On errors
+ * @throws DDatabaseException On errors
+ */
+
+ void redo(
+ LFileModel model,
+ LDatabaseTransactionType transaction)
+ throws LException, DDatabaseException;
+
+ /**
+ * @return The command as a set of properties
+ */
+
+ Properties toProperties();
+
+ /**
+ * @return A humanly-readable description of the operation
+ */
+
+ String describe();
+
+ /**
+ * Serialize this command as XML properties.
+ *
+ * @return The serialized bytes
+ */
+
+ default byte[] serialize()
+ {
+ try (var out = new ByteArrayOutputStream()) {
+ final var p = this.toProperties();
+ p.setProperty("@Type", this.getClass().getCanonicalName());
+ p.storeToXML(out, "", StandardCharsets.UTF_8);
+ out.flush();
+ return out.toByteArray();
+ } catch (final IOException e) {
+ throw new UncheckedIOException(e);
+ }
+ }
+}
diff --git a/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/internal/LCommandUndoable.java b/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/internal/LCommandUndoable.java
new file mode 100644
index 0000000..854bb01
--- /dev/null
+++ b/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/internal/LCommandUndoable.java
@@ -0,0 +1,39 @@
+/*
+ * 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;
+
+/**
+ * A value that indicates whether a command can be undone. Even commands that
+ * execute state changes might sometimes not support being undone (because,
+ * for example, the command execution didn't result in any actual state changes).
+ */
+
+public enum LCommandUndoable
+{
+ /**
+ * The command can be undone.
+ */
+
+ COMMAND_UNDOABLE,
+
+ /**
+ * The command can't be undone.
+ */
+
+ COMMAND_NOT_UNDOABLE
+}
diff --git a/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/internal/LCommands.java b/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/internal/LCommands.java
new file mode 100644
index 0000000..e9592cc
--- /dev/null
+++ b/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/internal/LCommands.java
@@ -0,0 +1,62 @@
+/*
+ * 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 java.util.Objects;
+import java.util.Properties;
+import java.util.ServiceLoader;
+
+/**
+ * The command directory.
+ */
+
+public final class LCommands
+{
+ private LCommands()
+ {
+
+ }
+
+ /**
+ * Find a suitable command for the given properties.
+ *
+ * @param properties The properties
+ *
+ * @return The command
+ */
+
+ public static LCommandType> forProperties(
+ final Properties properties)
+ {
+ final var type = properties.getProperty("@Type");
+ Objects.requireNonNull(type, "type");
+
+ final var factories =
+ ServiceLoader.load(LCommandFactoryType.class);
+
+ for (final LCommandFactoryType> factory : factories) {
+ if (Objects.equals(factory.commandClass(), type)) {
+ return factory.constructor().apply(properties);
+ }
+ }
+
+ throw new IllegalStateException(
+ "No command available of type %s".formatted(type)
+ );
+ }
+}
diff --git a/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/internal/LDatabase.java b/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/internal/LDatabase.java
new file mode 100644
index 0000000..a59171d
--- /dev/null
+++ b/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/internal/LDatabase.java
@@ -0,0 +1,69 @@
+/*
+ * 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.darco.api.DDatabaseAbstract;
+import com.io7m.darco.api.DDatabaseException;
+import com.io7m.jmulticlose.core.CloseableCollectionType;
+import io.opentelemetry.api.trace.Span;
+import org.sqlite.SQLiteDataSource;
+
+import java.sql.Connection;
+import java.sql.SQLException;
+import java.util.Collection;
+import java.util.Map;
+
+final class LDatabase
+ extends DDatabaseAbstract<
+ LDatabaseConfiguration,
+ LDatabaseConnectionType,
+ LDatabaseTransactionType,
+ LDatabaseQueryProviderType, ?, ?>>
+ implements LDatabaseType
+{
+ LDatabase(
+ final LDatabaseConfiguration inConfiguration,
+ final SQLiteDataSource inDataSource,
+ final Collection> queryProviders,
+ final CloseableCollectionType resources)
+ {
+ super(inConfiguration, inDataSource, queryProviders, resources);
+ }
+
+ private static void setWALMode(
+ final Connection connection)
+ throws DDatabaseException
+ {
+ try (var st = connection.createStatement()) {
+ st.execute("PRAGMA journal_mode=WAL;");
+ } catch (final SQLException e) {
+ throw DDatabaseException.ofException(e);
+ }
+ }
+
+ @Override
+ protected LDatabaseConnectionType createConnection(
+ final Span span,
+ final Connection connection,
+ final Map, LDatabaseQueryProviderType, ?, ?>> queries)
+ throws DDatabaseException
+ {
+ setWALMode(connection);
+ return new LDatabaseConnection(this, span, connection, queries);
+ }
+}
diff --git a/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/internal/LDatabaseConfiguration.java b/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/internal/LDatabaseConfiguration.java
new file mode 100644
index 0000000..6cca17b
--- /dev/null
+++ b/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/internal/LDatabaseConfiguration.java
@@ -0,0 +1,63 @@
+/*
+ * 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.darco.api.DDatabaseCreate;
+import com.io7m.darco.api.DDatabaseTelemetryType;
+import com.io7m.darco.api.DDatabaseUpgrade;
+import com.io7m.darco.sqlite.DSDatabaseConfigurationType;
+
+import java.nio.file.Path;
+import java.util.Objects;
+
+/**
+ * The configuration information for the laurel SQLite database.
+ *
+ * @param telemetry The telemetry interface
+ * @param create The database creation option
+ * @param upgrade The database upgrade option
+ * @param file The database file
+ * @param readOnly If the database should be read-only
+ */
+
+public record LDatabaseConfiguration(
+ DDatabaseTelemetryType telemetry,
+ DDatabaseCreate create,
+ DDatabaseUpgrade upgrade,
+ Path file,
+ boolean readOnly)
+ implements DSDatabaseConfigurationType
+{
+ /**
+ * The configuration information for the laurel SQLite database.
+ *
+ * @param telemetry The telemetry interface
+ * @param create The database creation option
+ * @param upgrade The database upgrade option
+ * @param file The database file
+ * @param readOnly If the database should be read-only
+ */
+
+ public LDatabaseConfiguration
+ {
+ Objects.requireNonNull(telemetry, "telemetry");
+ Objects.requireNonNull(create, "create");
+ Objects.requireNonNull(upgrade, "upgrade");
+ Objects.requireNonNull(file, "file");
+ }
+}
diff --git a/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/internal/LDatabaseConnection.java b/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/internal/LDatabaseConnection.java
new file mode 100644
index 0000000..31a2041
--- /dev/null
+++ b/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/internal/LDatabaseConnection.java
@@ -0,0 +1,73 @@
+/*
+ * 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.darco.api.DDatabaseConnectionAbstract;
+import com.io7m.darco.api.DDatabaseTransactionCloseBehavior;
+import io.opentelemetry.api.trace.Span;
+import org.jooq.DSLContext;
+import org.jooq.SQLDialect;
+import org.jooq.conf.RenderNameCase;
+import org.jooq.conf.Settings;
+import org.jooq.impl.DSL;
+
+import java.sql.Connection;
+import java.util.Map;
+
+final class LDatabaseConnection
+ extends DDatabaseConnectionAbstract<
+ LDatabaseConfiguration,
+ LDatabaseTransactionType,
+ LDatabaseQueryProviderType, ?, ?>>
+ implements LDatabaseConnectionType
+{
+ private static final Settings SETTINGS =
+ new Settings().withRenderNameCase(RenderNameCase.LOWER);
+
+ LDatabaseConnection(
+ final LDatabase database,
+ final Span span,
+ final Connection connection,
+ final Map, LDatabaseQueryProviderType, ?, ?>> queries)
+ {
+ super(database.configuration(), span, connection, queries);
+ }
+
+ @Override
+ protected LDatabaseTransactionType createTransaction(
+ final DDatabaseTransactionCloseBehavior closeBehavior,
+ final Span transactionSpan,
+ final Map, LDatabaseQueryProviderType, ?, ?>> queries)
+ {
+ final var transaction = new LDatabaseTransaction(
+ closeBehavior,
+ this.configuration(),
+ this,
+ transactionSpan,
+ queries
+ );
+
+ transaction.put(DSLContext.class, this.createContext());
+ return transaction;
+ }
+
+ private DSLContext createContext()
+ {
+ return DSL.using(this.connection(), SQLDialect.SQLITE, SETTINGS);
+ }
+}
diff --git a/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/internal/LDatabaseConnectionType.java b/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/internal/LDatabaseConnectionType.java
new file mode 100644
index 0000000..3f23c24
--- /dev/null
+++ b/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/internal/LDatabaseConnectionType.java
@@ -0,0 +1,30 @@
+/*
+ * 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.darco.api.DDatabaseConnectionType;
+
+/**
+ * The type of laurel SQLite database connections.
+ */
+
+public interface LDatabaseConnectionType
+ extends DDatabaseConnectionType
+{
+
+}
diff --git a/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/internal/LDatabaseFactory.java b/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/internal/LDatabaseFactory.java
new file mode 100644
index 0000000..0b77662
--- /dev/null
+++ b/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/internal/LDatabaseFactory.java
@@ -0,0 +1,117 @@
+/*
+ * 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.darco.api.DDatabaseException;
+import com.io7m.darco.sqlite.DSDatabaseFactory;
+import com.io7m.jmulticlose.core.CloseableCollectionType;
+import com.io7m.lanark.core.RDottedName;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.sqlite.SQLiteConfig;
+import org.sqlite.SQLiteDataSource;
+
+import java.io.InputStream;
+import java.util.List;
+import java.util.ServiceLoader;
+import java.util.stream.Collectors;
+
+/**
+ * The main database factory.
+ */
+
+public final class LDatabaseFactory
+ extends DSDatabaseFactory<
+ LDatabaseConfiguration,
+ LDatabaseConnectionType,
+ LDatabaseTransactionType,
+ LDatabaseQueryProviderType, ?, ?>,
+ LDatabaseType>
+ implements LDatabaseFactoryType
+{
+ private static final Logger LOG =
+ LoggerFactory.getLogger(LDatabaseFactory.class);
+
+ /**
+ * The main database factory.
+ */
+
+ public LDatabaseFactory()
+ {
+
+ }
+
+ @Override
+ protected RDottedName applicationId()
+ {
+ return new RDottedName("com.io7m.laurel");
+ }
+
+ @Override
+ protected Logger logger()
+ {
+ return LOG;
+ }
+
+ @Override
+ protected LDatabaseType onCreateDatabase(
+ final LDatabaseConfiguration configuration,
+ final SQLiteDataSource source,
+ final List> queryProviders,
+ final CloseableCollectionType resources)
+ {
+ return new LDatabase(
+ configuration,
+ source,
+ queryProviders,
+ resources
+ );
+ }
+
+ @Override
+ protected InputStream onRequireDatabaseSchemaXML()
+ {
+ return LDatabaseFactory.class.getResourceAsStream(
+ "/com/io7m/laurel/filemodel/internal/database.xml"
+ );
+ }
+
+ @Override
+ protected void onEvent(
+ final String message)
+ {
+
+ }
+
+ @Override
+ protected void onAdjustSQLiteConfig(
+ final SQLiteConfig config)
+ {
+
+ }
+
+ @Override
+ protected List> onRequireDatabaseQueryProviders()
+ {
+ return ServiceLoader.load(LDatabaseQueryProviderType.class)
+ .stream()
+ .map(ServiceLoader.Provider::get)
+ .map(x -> (LDatabaseQueryProviderType, ?, ?>) x)
+ .collect(Collectors.toList());
+ }
+}
diff --git a/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/internal/LDatabaseFactoryType.java b/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/internal/LDatabaseFactoryType.java
new file mode 100644
index 0000000..7666a06
--- /dev/null
+++ b/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/internal/LDatabaseFactoryType.java
@@ -0,0 +1,35 @@
+/*
+ * 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.darco.api.DDatabaseFactoryType;
+
+/**
+ * The type of laurel SQLite database factories.
+ */
+
+public interface LDatabaseFactoryType
+ extends DDatabaseFactoryType<
+ LDatabaseConfiguration,
+ LDatabaseConnectionType,
+ LDatabaseTransactionType,
+ LDatabaseQueryProviderType, ?, ?>,
+ LDatabaseType>
+{
+
+}
diff --git a/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/internal/LDatabaseQueryAbstract.java b/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/internal/LDatabaseQueryAbstract.java
new file mode 100644
index 0000000..1def8cf
--- /dev/null
+++ b/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/internal/LDatabaseQueryAbstract.java
@@ -0,0 +1,37 @@
+/*
+ * 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.darco.api.DDatabaseQueryAbstract;
+
+/**
+ * An abstract query for the laurel SQLite database.
+ *
+ * @param The query parameters
+ * @param The query results
+ */
+
+public abstract class LDatabaseQueryAbstract
+ extends DDatabaseQueryAbstract
+{
+ protected LDatabaseQueryAbstract(
+ final LDatabaseTransactionType t)
+ {
+ super(t);
+ }
+}
diff --git a/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/internal/LDatabaseQueryProviderType.java b/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/internal/LDatabaseQueryProviderType.java
new file mode 100644
index 0000000..016a09b
--- /dev/null
+++ b/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/internal/LDatabaseQueryProviderType.java
@@ -0,0 +1,35 @@
+/*
+ * 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.darco.api.DDatabaseQueryProviderType;
+import com.io7m.darco.api.DDatabaseQueryType;
+
+/**
+ * The type of laurel SQLite database query providers.
+ *
+ * @param The type of query parameters
+ * @param The type of query results
+ * @param The precise type of query
+ */
+
+public interface LDatabaseQueryProviderType>
+ extends DDatabaseQueryProviderType
+{
+
+}
diff --git a/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/internal/LDatabaseTransaction.java b/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/internal/LDatabaseTransaction.java
new file mode 100644
index 0000000..e0c12ba
--- /dev/null
+++ b/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/internal/LDatabaseTransaction.java
@@ -0,0 +1,49 @@
+/*
+ * 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.darco.api.DDatabaseTransactionAbstract;
+import com.io7m.darco.api.DDatabaseTransactionCloseBehavior;
+import io.opentelemetry.api.trace.Span;
+
+import java.util.Map;
+
+final class LDatabaseTransaction
+ extends DDatabaseTransactionAbstract<
+ LDatabaseConfiguration,
+ LDatabaseConnectionType,
+ LDatabaseTransactionType,
+ LDatabaseQueryProviderType, ?, ?>>
+ implements LDatabaseTransactionType
+{
+ LDatabaseTransaction(
+ final DDatabaseTransactionCloseBehavior closeBehavior,
+ final LDatabaseConfiguration inConfiguration,
+ final LDatabaseConnectionType inConnection,
+ final Span inTransactionScope,
+ final Map, LDatabaseQueryProviderType, ?, ?>> queries)
+ {
+ super(
+ closeBehavior,
+ inConfiguration,
+ inConnection,
+ inTransactionScope,
+ queries
+ );
+ }
+}
diff --git a/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/internal/LDatabaseTransactionType.java b/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/internal/LDatabaseTransactionType.java
new file mode 100644
index 0000000..f738969
--- /dev/null
+++ b/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/internal/LDatabaseTransactionType.java
@@ -0,0 +1,30 @@
+/*
+ * 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.darco.api.DDatabaseTransactionType;
+
+/**
+ * The type of laurel SQLite database transactions.
+ */
+
+public interface LDatabaseTransactionType
+ extends DDatabaseTransactionType
+{
+
+}
diff --git a/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/internal/LDatabaseType.java b/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/internal/LDatabaseType.java
new file mode 100644
index 0000000..9c264e8
--- /dev/null
+++ b/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/internal/LDatabaseType.java
@@ -0,0 +1,34 @@
+/*
+ * 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.darco.api.DDatabaseType;
+
+/**
+ * The type of laurel SQLite databases.
+ */
+
+public interface LDatabaseType
+ extends DDatabaseType<
+ LDatabaseConfiguration,
+ LDatabaseConnectionType,
+ LDatabaseTransactionType,
+ LDatabaseQueryProviderType, ?, ?>>
+{
+
+}
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
new file mode 100644
index 0000000..d70d44f
--- /dev/null
+++ b/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/internal/LFileModel.java
@@ -0,0 +1,618 @@
+/*
+ * 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.darco.api.DDatabaseCreate;
+import com.io7m.darco.api.DDatabaseException;
+import com.io7m.darco.api.DDatabaseTelemetryNoOp;
+import com.io7m.darco.api.DDatabaseUpgrade;
+import com.io7m.jattribute.core.AttributeReadableType;
+import com.io7m.jattribute.core.AttributeType;
+import com.io7m.jattribute.core.Attributes;
+import com.io7m.laurel.filemodel.LFileModelType;
+import com.io7m.laurel.model.LException;
+import com.io7m.laurel.model.LImage;
+import com.io7m.laurel.model.LTag;
+import org.jooq.DSLContext;
+import org.jooq.Record;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.net.URI;
+import java.nio.file.Path;
+import java.time.OffsetDateTime;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Properties;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.locks.ReentrantLock;
+
+import static com.io7m.laurel.filemodel.internal.Tables.REDO;
+import static com.io7m.laurel.filemodel.internal.Tables.UNDO;
+import static java.time.ZoneOffset.UTC;
+
+/**
+ * The file model.
+ */
+
+public final class LFileModel implements LFileModelType
+{
+ private static final Logger LOG =
+ LoggerFactory.getLogger(LFileModel.class);
+
+ private static final Attributes ATTRIBUTES =
+ Attributes.create(throwable -> {
+ LOG.error("Uncaught attribute exception: ", throwable);
+ });
+
+ private final AttributeType> imagesAll;
+ private final AttributeType> tagsAll;
+ private final AttributeType> tagsAssigned;
+ private final AttributeType>> redo;
+ private final AttributeType>> undo;
+ private final AttributeType> imageSelected;
+ private final AttributeType> redoText;
+ private final AttributeType> undoText;
+ private final ConcurrentHashMap attributes;
+ private final LDatabaseType database;
+ private final ReentrantLock commandLock;
+
+ private LFileModel(
+ final LDatabaseType inDatabase)
+ {
+ this.database =
+ Objects.requireNonNull(inDatabase, "database");
+ this.tagsAll =
+ ATTRIBUTES.create(List.of());
+ this.tagsAssigned =
+ ATTRIBUTES.create(List.of());
+ this.imagesAll =
+ ATTRIBUTES.create(List.of());
+ this.imageSelected =
+ ATTRIBUTES.create(Optional.empty());
+ this.undo =
+ ATTRIBUTES.create(Optional.empty());
+ this.undoText =
+ this.undo.map(o -> o.map(LCommandType::describe));
+ this.redo =
+ ATTRIBUTES.create(Optional.empty());
+ this.redoText =
+ this.redo.map(o -> o.map(LCommandType::describe));
+ this.commandLock =
+ new ReentrantLock();
+ this.attributes =
+ new ConcurrentHashMap<>();
+ }
+
+ /**
+ * Open a file model.
+ *
+ * @param file The file
+ * @param readOnly {@code true} if the file should be read-only
+ *
+ * @return A file model
+ *
+ * @throws LException On errors
+ */
+
+ public static LFileModelType open(
+ final Path file,
+ final boolean readOnly)
+ throws LException
+ {
+ try {
+ final var databases =
+ new LDatabaseFactory();
+
+ if (readOnly) {
+ return new LFileModel(
+ databases.open(
+ new LDatabaseConfiguration(
+ DDatabaseTelemetryNoOp.get(),
+ DDatabaseCreate.DO_NOT_CREATE_DATABASE,
+ DDatabaseUpgrade.DO_NOT_UPGRADE_DATABASE,
+ file,
+ readOnly
+ ),
+ event -> {
+
+ }
+ )
+ );
+ }
+
+ return new LFileModel(
+ databases.open(
+ new LDatabaseConfiguration(
+ DDatabaseTelemetryNoOp.get(),
+ DDatabaseCreate.CREATE_DATABASE,
+ DDatabaseUpgrade.UPGRADE_DATABASE,
+ file,
+ readOnly
+ ),
+ event -> {
+
+ }
+ )
+ );
+ } catch (final DDatabaseException e) {
+ throw new LException(
+ e.getMessage(),
+ e,
+ e.errorCode(),
+ e.attributes(),
+ e.remediatingAction()
+ );
+ }
+ }
+
+ private static long nowMilliseconds()
+ {
+ return OffsetDateTime.now(UTC)
+ .toInstant()
+ .toEpochMilli();
+ }
+
+ private static void dbUndoMoveToRedo(
+ final LDatabaseTransactionType t,
+ final Record oldCommandRec)
+ {
+ final var context =
+ t.get(DSLContext.class);
+
+ final var id =
+ oldCommandRec.get(UNDO.UNDO_ID);
+
+ context.deleteFrom(UNDO)
+ .where(UNDO.UNDO_ID.eq(id))
+ .execute();
+
+ context.insertInto(REDO)
+ .set(REDO.REDO_ID, id)
+ .set(REDO.REDO_DESCRIPTION, oldCommandRec.get(UNDO.UNDO_DESCRIPTION))
+ .set(REDO.REDO_DATA, oldCommandRec.get(UNDO.UNDO_DATA))
+ .set(REDO.REDO_TIME, oldCommandRec.get(UNDO.UNDO_TIME))
+ .execute();
+ }
+
+ private static void dbRedoMoveToUndo(
+ final LDatabaseTransactionType t,
+ final Record oldCommandRec)
+ {
+ final var context =
+ t.get(DSLContext.class);
+
+ final var id =
+ oldCommandRec.get(REDO.REDO_ID);
+
+ context.deleteFrom(REDO)
+ .where(REDO.REDO_ID.eq(id))
+ .execute();
+
+ context.insertInto(UNDO)
+ .set(UNDO.UNDO_ID, id)
+ .set(UNDO.UNDO_DESCRIPTION, oldCommandRec.get(REDO.REDO_DESCRIPTION))
+ .set(UNDO.UNDO_DATA, oldCommandRec.get(REDO.REDO_DATA))
+ .set(UNDO.UNDO_TIME, oldCommandRec.get(REDO.REDO_TIME))
+ .execute();
+ }
+
+ private static LCommandType> parseUndoCommandFromProperties(
+ final org.jooq.Record rec)
+ throws IOException
+ {
+ final var properties =
+ new Properties();
+
+ try (var stream = new ByteArrayInputStream(rec.get(UNDO.UNDO_DATA))) {
+ properties.loadFromXML(stream);
+ }
+
+ return LCommands.forProperties(properties);
+ }
+
+ private static LCommandType> parseRedoCommandFromProperties(
+ final org.jooq.Record rec)
+ throws IOException
+ {
+ final var properties =
+ new Properties();
+
+ try (var stream = new ByteArrayInputStream(rec.get(REDO.REDO_DATA))) {
+ properties.loadFromXML(stream);
+ }
+
+ return LCommands.forProperties(properties);
+ }
+
+ private static Optional dbUndoGetTip(
+ final LDatabaseTransactionType t)
+ {
+ final var context =
+ t.get(DSLContext.class);
+
+ return context.select(
+ UNDO.UNDO_DATA,
+ UNDO.UNDO_TIME,
+ UNDO.UNDO_DESCRIPTION,
+ UNDO.UNDO_ID
+ ).from(UNDO)
+ .orderBy(UNDO.UNDO_TIME.desc(), UNDO.UNDO_ID.desc())
+ .limit(1)
+ .fetchOptional()
+ .map(x -> x);
+ }
+
+ private static Optional dbRedoGetTip(
+ final LDatabaseTransactionType t)
+ {
+ final var context =
+ t.get(DSLContext.class);
+
+ return context.select(
+ REDO.REDO_DATA,
+ REDO.REDO_TIME,
+ REDO.REDO_DESCRIPTION,
+ REDO.REDO_ID
+ ).from(REDO)
+ .orderBy(REDO.REDO_TIME.asc(), REDO.REDO_ID.asc())
+ .limit(1)
+ .fetchOptional()
+ .map(x -> x);
+ }
+
+ void setAttribute(
+ final String name,
+ final String value)
+ {
+ Objects.requireNonNull(name, "name");
+ Objects.requireNonNull(value, "value");
+
+ this.attributes.put(name, value);
+ }
+
+ void setAttribute(
+ final String name,
+ final Object value)
+ {
+ this.setAttribute(name, value.toString());
+ }
+
+ @Override
+ public CompletableFuture> tagAdd(
+ final LTag text)
+ {
+ return this.runCommand(new LCommandTagAdd(), text);
+ }
+
+ @Override
+ public CompletableFuture> imageAdd(
+ final String name,
+ final Path file,
+ final Optional source)
+ {
+ Objects.requireNonNull(name, "name");
+ Objects.requireNonNull(file, "file");
+ Objects.requireNonNull(source, "source");
+
+ return this.runCommand(
+ new LCommandImageAdd(),
+ new LImageRequest(name, file, source)
+ );
+ }
+
+ @Override
+ public CompletableFuture> imageSelect(
+ final Optional name)
+ {
+ Objects.requireNonNull(name, "name");
+
+ return this.runCommand(
+ new LCommandImageSelect(),
+ name
+ );
+ }
+
+ private >
+ CompletableFuture>
+ runCommand(
+ final C command,
+ final P parameters)
+ {
+ final var future = new CompletableFuture();
+ Thread.ofVirtual()
+ .start(() -> {
+ try {
+ this.executeCommandLocked(command, parameters);
+ future.complete(null);
+ } catch (final Throwable e) {
+ future.completeExceptionally(e);
+ }
+ });
+ return future;
+ }
+
+ private >
+ void executeCommandLocked(
+ final C command,
+ final P parameters)
+ throws Exception
+ {
+ this.commandLock.lock();
+
+ try {
+ this.attributes.clear();
+
+ try (var t = this.database.openTransaction()) {
+ final var undoable =
+ command.execute(this, t, parameters);
+
+ switch (undoable) {
+ case COMMAND_UNDOABLE -> {
+ final var context = t.get(DSLContext.class);
+ context.insertInto(UNDO)
+ .set(UNDO.UNDO_DESCRIPTION, command.describe())
+ .set(UNDO.UNDO_TIME, Long.valueOf(nowMilliseconds()))
+ .set(UNDO.UNDO_DATA, command.serialize())
+ .execute();
+ }
+ case COMMAND_NOT_UNDOABLE -> {
+
+ }
+ }
+
+ t.commit();
+
+ switch (undoable) {
+ case COMMAND_UNDOABLE -> {
+ this.undo.set(Optional.of(command));
+ }
+ case COMMAND_NOT_UNDOABLE -> {
+
+ }
+ }
+ } catch (final DDatabaseException e) {
+ throw new LException(
+ e.getMessage(),
+ e,
+ e.errorCode(),
+ e.attributes(),
+ e.remediatingAction()
+ );
+ }
+ } finally {
+ this.commandLock.unlock();
+ }
+ }
+
+ @Override
+ public void close()
+ throws LException
+ {
+ try {
+ this.database.close();
+ } catch (final DDatabaseException e) {
+ throw new LException(
+ e.getMessage(),
+ e,
+ e.errorCode(),
+ e.attributes(),
+ e.remediatingAction()
+ );
+ }
+ }
+
+ @Override
+ public AttributeReadableType> imageSelected()
+ {
+ return this.imageSelected;
+ }
+
+ @Override
+ public AttributeReadableType> imageList()
+ {
+ return this.imagesAll;
+ }
+
+ @Override
+ public AttributeReadableType> tagList()
+ {
+ return this.tagsAll;
+ }
+
+ @Override
+ public AttributeReadableType> tagsAssigned()
+ {
+ return this.tagsAssigned;
+ }
+
+ @Override
+ public AttributeReadableType> undoText()
+ {
+ return this.undoText;
+ }
+
+ @Override
+ public CompletableFuture> undo()
+ {
+ final var future = new CompletableFuture();
+ Thread.ofVirtual()
+ .start(() -> {
+ try {
+ this.executeUndo();
+ future.complete(null);
+ } catch (final Throwable e) {
+ future.completeExceptionally(e);
+ }
+ });
+ return future;
+ }
+
+ private void executeUndo()
+ throws Exception
+ {
+ this.commandLock.lock();
+
+ try {
+ this.attributes.clear();
+
+ try (var t = this.database.openTransaction()) {
+ final var oldCommandRecOpt = dbUndoGetTip(t);
+ if (oldCommandRecOpt.isEmpty()) {
+ return;
+ }
+
+ final var oldCommandRec =
+ oldCommandRecOpt.get();
+ final var oldCommand =
+ parseUndoCommandFromProperties(oldCommandRec);
+
+ oldCommand.undo(this, t);
+ dbUndoMoveToRedo(t, oldCommandRec);
+ t.commit();
+
+ this.redo.set(Optional.of(oldCommand));
+
+ final var newCommandRecOpt = dbUndoGetTip(t);
+ if (newCommandRecOpt.isPresent()) {
+ this.undo.set(Optional.of(
+ parseUndoCommandFromProperties(newCommandRecOpt.get()))
+ );
+ } else {
+ this.undo.set(Optional.empty());
+ }
+
+ } catch (final DDatabaseException e) {
+ this.attributes.putAll(e.attributes());
+
+ throw new LException(
+ e.getMessage(),
+ e,
+ e.errorCode(),
+ Map.copyOf(this.attributes),
+ e.remediatingAction()
+ );
+ }
+ } finally {
+ this.commandLock.unlock();
+ }
+ }
+
+ @Override
+ public CompletableFuture> redo()
+ {
+ final var future = new CompletableFuture();
+ Thread.ofVirtual()
+ .start(() -> {
+ try {
+ this.executeRedo();
+ future.complete(null);
+ } catch (final Throwable e) {
+ future.completeExceptionally(e);
+ }
+ });
+ return future;
+ }
+
+ @Override
+ public AttributeReadableType> redoText()
+ {
+ return this.redoText;
+ }
+
+ private void executeRedo()
+ throws Exception
+ {
+ this.commandLock.lock();
+
+ try {
+ this.attributes.clear();
+
+ try (var t = this.database.openTransaction()) {
+ final var oldCommandRecOpt = dbRedoGetTip(t);
+ if (oldCommandRecOpt.isEmpty()) {
+ return;
+ }
+
+ final var oldCommandRec =
+ oldCommandRecOpt.get();
+ final var oldCommand =
+ parseRedoCommandFromProperties(oldCommandRec);
+
+ oldCommand.redo(this, t);
+ dbRedoMoveToUndo(t, oldCommandRec);
+ t.commit();
+
+ this.undo.set(Optional.of(oldCommand));
+
+ final var newCommandRecOpt = dbRedoGetTip(t);
+ if (newCommandRecOpt.isPresent()) {
+ this.redo.set(Optional.of(
+ parseRedoCommandFromProperties(newCommandRecOpt.get()))
+ );
+ } else {
+ this.redo.set(Optional.empty());
+ }
+ } catch (final DDatabaseException e) {
+ throw new LException(
+ e.getMessage(),
+ e,
+ e.errorCode(),
+ e.attributes(),
+ e.remediatingAction()
+ );
+ }
+ } finally {
+ this.commandLock.unlock();
+ }
+ }
+
+ void setImagesAll(
+ final List images)
+ {
+ this.imagesAll.set(Objects.requireNonNull(images, "images"));
+ }
+
+ void setTagsAll(
+ final List tags)
+ {
+ this.tagsAll.set(Objects.requireNonNull(tags, "tags"));
+ }
+
+ Map attributes()
+ {
+ return Map.copyOf(this.attributes);
+ }
+
+ void setTagsAssigned(
+ final List tags)
+ {
+ this.tagsAssigned.set(tags);
+ }
+
+ void setImageSelected(
+ final Optional image)
+ {
+ this.imageSelected.set(image);
+ }
+}
diff --git a/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/internal/LImageRequest.java b/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/internal/LImageRequest.java
new file mode 100644
index 0000000..a52cc6b
--- /dev/null
+++ b/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/internal/LImageRequest.java
@@ -0,0 +1,52 @@
+/*
+ * 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 java.net.URI;
+import java.nio.file.Path;
+import java.util.Objects;
+import java.util.Optional;
+
+/**
+ * An image request.
+ *
+ * @param name The name
+ * @param file The file
+ * @param source The source
+ */
+
+public record LImageRequest(
+ String name,
+ Path file,
+ Optional source)
+{
+ /**
+ * An image request.
+ *
+ * @param name The name
+ * @param file The file
+ * @param source The source
+ */
+
+ public LImageRequest
+ {
+ Objects.requireNonNull(name, "name");
+ Objects.requireNonNull(file, "file");
+ Objects.requireNonNull(source, "source");
+ }
+}
diff --git a/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/internal/package-info.java b/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/internal/package-info.java
new file mode 100644
index 0000000..945ede5
--- /dev/null
+++ b/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/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 (File model)
+ */
+
+@Version("1.0.0")
+package com.io7m.laurel.filemodel.internal;
+
+import org.osgi.annotation.versioning.Version;
diff --git a/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/package-info.java b/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/package-info.java
new file mode 100644
index 0000000..d0cdc09
--- /dev/null
+++ b/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/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 (File model)
+ */
+
+@Export
+@Version("1.0.0")
+package com.io7m.laurel.filemodel;
+
+import org.osgi.annotation.bundle.Export;
+import org.osgi.annotation.versioning.Version;
diff --git a/com.io7m.laurel.filemodel/src/main/java/module-info.java b/com.io7m.laurel.filemodel/src/main/java/module-info.java
new file mode 100644
index 0000000..035767f
--- /dev/null
+++ b/com.io7m.laurel.filemodel/src/main/java/module-info.java
@@ -0,0 +1,54 @@
+/*
+ * 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.
+ */
+
+import com.io7m.laurel.filemodel.internal.LCommandFactoryType;
+import com.io7m.laurel.filemodel.internal.LCommandImageAdd;
+import com.io7m.laurel.filemodel.internal.LCommandImageSelect;
+import com.io7m.laurel.filemodel.internal.LCommandTagAdd;
+
+/**
+ * Image caption management (File model)
+ */
+
+module com.io7m.laurel.filemodel
+{
+ uses com.io7m.laurel.filemodel.internal.LDatabaseQueryProviderType;
+ uses com.io7m.laurel.filemodel.internal.LCommandFactoryType;
+
+ requires static org.osgi.annotation.bundle;
+ requires static org.osgi.annotation.versioning;
+
+ requires com.io7m.darco.api;
+ requires com.io7m.darco.sqlite;
+ requires com.io7m.jmulticlose.core;
+ requires com.io7m.lanark.core;
+ requires io.opentelemetry.api;
+ requires org.jooq;
+ requires org.slf4j;
+ requires org.xerial.sqlitejdbc;
+ requires com.io7m.laurel.model;
+ requires com.io7m.jattribute.core;
+ requires java.desktop;
+
+ provides LCommandFactoryType with
+ LCommandTagAdd,
+ LCommandImageAdd,
+ LCommandImageSelect;
+
+ exports com.io7m.laurel.filemodel;
+ exports com.io7m.laurel.filemodel.internal
+ to com.io7m.laurel.tests;
+}
diff --git a/com.io7m.laurel.filemodel/src/main/jooq/configuration.xml b/com.io7m.laurel.filemodel/src/main/jooq/configuration.xml
new file mode 100644
index 0000000..576ddc6
--- /dev/null
+++ b/com.io7m.laurel.filemodel/src/main/jooq/configuration.xml
@@ -0,0 +1,73 @@
+
+
+
+ false
+
+
+
+ org.jooq.meta.extensions.ddl.DDLDatabase
+
+
+
+ BIGINT
+ .*
+ INTEGER
+ ALL
+ COLUMN
+
+
+
+
+
+
+
+ scripts
+ target/database.sql
+
+
+
+
+ sort
+ semantic
+
+
+
+
+ unqualifiedSchema
+ none
+
+
+
+
+ defaultNameCase
+ as_is
+
+
+
+
+
+ com.io7m.laurel.filemodel.internal
+ target/generated-sources/jooq
+
+
+
\ No newline at end of file
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
new file mode 100644
index 0000000..85ef6ec
--- /dev/null
+++ b/com.io7m.laurel.filemodel/src/main/resources/com/io7m/laurel/filemodel/internal/database.xml
@@ -0,0 +1,147 @@
+
+
+
+
+
+
+ The schema version table stores the current version of the database schema. Implementations are expected to query
+ this table on connecting to the database in order to ensure that the calling code is compatible with the tables in
+ the database.
+
+
+
+
+
+
+
+ The image_blobs table stores the image blobs.
+
+
+
+
+
+ The images table stores the complete set of images.
+
+
+
+
+
+ The tags table stores the complete set of available tags.
+
+
+
+
+
+ The image_tags table stores the associations between images and tags.
+
+
+
+
+
+ The undo table stores the undo history.
+
+
+
+
+
+ The redo table stores the redo history.
+
+
+
+
+
+
diff --git a/com.io7m.laurel.gui/src/main/java/com/io7m/laurel/gui/internal/model/LModel.java b/com.io7m.laurel.gui/src/main/java/com/io7m/laurel/gui/internal/model/LModel.java
index a08d0fe..b6a2aaa 100644
--- a/com.io7m.laurel.gui/src/main/java/com/io7m/laurel/gui/internal/model/LModel.java
+++ b/com.io7m.laurel.gui/src/main/java/com/io7m/laurel/gui/internal/model/LModel.java
@@ -18,7 +18,7 @@
package com.io7m.laurel.gui.internal.model;
import com.io7m.laurel.gui.internal.model.LModelFileStatusType.None;
-import com.io7m.laurel.model.LImage;
+import com.io7m.laurel.model.LOldImage;
import com.io7m.laurel.model.LImageCaption;
import com.io7m.laurel.model.LImageCaptionID;
import com.io7m.laurel.model.LImageID;
@@ -219,7 +219,7 @@ public LImageSet createImageSet()
final var outCaptions =
new TreeMap();
final var outImages =
- new TreeMap();
+ new TreeMap();
for (final var entry : this.captions.entrySet()) {
final var caption =
@@ -240,7 +240,7 @@ public LImageSet createImageSet()
directory.relativize(imageFileName);
final var image =
- new LImage(
+ new LOldImage(
entry.getKey(),
savedImageFileName.toString(),
new TreeSet<>(
diff --git a/com.io7m.laurel.io/src/main/java/com/io7m/laurel/io/LImporters.java b/com.io7m.laurel.io/src/main/java/com/io7m/laurel/io/LImporters.java
index 70fd539..6b4a892 100644
--- a/com.io7m.laurel.io/src/main/java/com/io7m/laurel/io/LImporters.java
+++ b/com.io7m.laurel.io/src/main/java/com/io7m/laurel/io/LImporters.java
@@ -18,7 +18,7 @@
package com.io7m.laurel.io;
import com.io7m.jdeferthrow.core.ExceptionTracker;
-import com.io7m.laurel.model.LImage;
+import com.io7m.laurel.model.LOldImage;
import com.io7m.laurel.model.LImageCaption;
import com.io7m.laurel.model.LImageCaptionID;
import com.io7m.laurel.model.LImageID;
@@ -83,7 +83,7 @@ public static LImageSet importDirectory(
new ExceptionTracker();
final var images =
- new TreeMap();
+ new TreeMap();
final var captions =
new TreeMap();
final var captionTexts =
@@ -125,7 +125,7 @@ public static LImageSet importDirectory(
return new LImageSet(List.of(), captions, images);
}
- private static LImage processImageFile(
+ private static LOldImage processImageFile(
final ExceptionTracker exceptionTracker,
final int fileIndex,
final Path sourceDirectory,
@@ -198,7 +198,7 @@ private static LImage processImageFile(
++captionIndex;
}
- return new LImage(
+ return new LOldImage(
new LImageID(
UUID.nameUUIDFromBytes(
file.toAbsolutePath()
diff --git a/com.io7m.laurel.io/src/main/java/com/io7m/laurel/io/internal/L1EImage.java b/com.io7m.laurel.io/src/main/java/com/io7m/laurel/io/internal/L1EImage.java
index e9d38cc..4fc9de6 100644
--- a/com.io7m.laurel.io/src/main/java/com/io7m/laurel/io/internal/L1EImage.java
+++ b/com.io7m.laurel.io/src/main/java/com/io7m/laurel/io/internal/L1EImage.java
@@ -21,7 +21,7 @@
import com.io7m.blackthorne.core.BTElementHandlerType;
import com.io7m.blackthorne.core.BTElementParsingContextType;
import com.io7m.blackthorne.core.BTQualifiedName;
-import com.io7m.laurel.model.LImage;
+import com.io7m.laurel.model.LOldImage;
import com.io7m.laurel.model.LImageCaptionID;
import com.io7m.laurel.model.LImageID;
import org.xml.sax.Attributes;
@@ -37,7 +37,7 @@
*/
public final class L1EImage
- implements BTElementHandlerType
+ implements BTElementHandlerType
{
private final TreeSet captions;
private LImageID imageId;
@@ -88,10 +88,10 @@ public void onChildValueProduced(
}
@Override
- public LImage onElementFinished(
+ public LOldImage onElementFinished(
final BTElementParsingContextType context)
{
- return new LImage(
+ return new LOldImage(
this.imageId,
this.fileName,
this.captions
diff --git a/com.io7m.laurel.io/src/main/java/com/io7m/laurel/io/internal/L1EImageSet.java b/com.io7m.laurel.io/src/main/java/com/io7m/laurel/io/internal/L1EImageSet.java
index f8fb9bc..1f9d6bd 100644
--- a/com.io7m.laurel.io/src/main/java/com/io7m/laurel/io/internal/L1EImageSet.java
+++ b/com.io7m.laurel.io/src/main/java/com/io7m/laurel/io/internal/L1EImageSet.java
@@ -21,7 +21,7 @@
import com.io7m.blackthorne.core.BTElementHandlerType;
import com.io7m.blackthorne.core.BTElementParsingContextType;
import com.io7m.blackthorne.core.BTQualifiedName;
-import com.io7m.laurel.model.LImage;
+import com.io7m.laurel.model.LOldImage;
import com.io7m.laurel.model.LImageCaption;
import com.io7m.laurel.model.LImageCaptionID;
import com.io7m.laurel.model.LImageID;
@@ -41,7 +41,7 @@
public final class L1EImageSet
implements BTElementHandlerType