diff --git a/checkstyle-filter.xml b/checkstyle-filter.xml
index fd0a250..d4366c4 100644
--- a/checkstyle-filter.xml
+++ b/checkstyle-filter.xml
@@ -12,8 +12,9 @@
-
+
diff --git a/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/LFileModelType.java b/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/LFileModelType.java
index 991b5e4..a564544 100644
--- a/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/LFileModelType.java
+++ b/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/LFileModelType.java
@@ -554,4 +554,18 @@ CompletableFuture> captionsPaste(
*/
void exportClear();
+
+ /**
+ * Change the source of an image.
+ *
+ * @param image The image
+ * @param source The source
+ *
+ * @return The operation in progress
+ */
+
+ CompletableFuture> imageSourceSet(
+ LImageID image,
+ URI source
+ );
}
diff --git a/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/internal/LCommandImageSourceSet.java b/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/internal/LCommandImageSourceSet.java
new file mode 100644
index 0000000..fb3a996
--- /dev/null
+++ b/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/internal/LCommandImageSourceSet.java
@@ -0,0 +1,164 @@
+/*
+ * 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 org.jooq.DSLContext;
+
+import java.util.Properties;
+
+import static com.io7m.laurel.filemodel.internal.Tables.IMAGES;
+
+/**
+ * Set an image source.
+ */
+
+public final class LCommandImageSourceSet
+ extends LCommandAbstract
+{
+ private SavedData savedData;
+
+ private record SavedData(
+ long savedImageId,
+ String savedOldURI,
+ String savedNewURI)
+ {
+
+ }
+
+ /**
+ * Set an image source.
+ */
+
+ public LCommandImageSourceSet()
+ {
+
+ }
+
+ /**
+ * @return A command factory
+ */
+
+ public static LCommandFactoryType provider()
+ {
+ return new LCommandFactory<>(
+ LCommandImageSourceSet.class.getCanonicalName(),
+ LCommandImageSourceSet::fromProperties
+ );
+ }
+
+ private static LCommandImageSourceSet fromProperties(
+ final Properties p)
+ {
+ final var c = new LCommandImageSourceSet();
+
+ c.savedData = new SavedData(
+ Long.parseUnsignedLong(p.getProperty("image")),
+ p.getProperty("oldURI"),
+ p.getProperty("newURI")
+ );
+
+ c.setExecuted(true);
+ return c;
+ }
+
+ @Override
+ protected LCommandUndoable onExecute(
+ final LFileModel model,
+ final LDatabaseTransactionType transaction,
+ final LImageSourceSet request)
+ throws LException
+ {
+ final var context =
+ transaction.get(DSLContext.class);
+
+ final var old =
+ context.select(IMAGES.IMAGE_SOURCE)
+ .from(IMAGES)
+ .where(IMAGES.IMAGE_ID.eq(request.image().value()))
+ .fetchOne(IMAGES.IMAGE_SOURCE);
+
+ final var updated =
+ context.update(IMAGES)
+ .set(IMAGES.IMAGE_SOURCE, request.source().toString())
+ .where(IMAGES.IMAGE_ID.eq(request.image().value()))
+ .execute();
+
+ if (updated == 0) {
+ return LCommandUndoable.COMMAND_NOT_UNDOABLE;
+ }
+
+ this.savedData = new SavedData(
+ request.image().value(),
+ old,
+ request.source().toString()
+ );
+
+ model.setImagesAll(LCommandModelUpdates.listImages(context));
+ return LCommandUndoable.COMMAND_UNDOABLE;
+ }
+
+
+ @Override
+ protected void onUndo(
+ final LFileModel model,
+ final LDatabaseTransactionType transaction)
+ {
+ final var context =
+ transaction.get(DSLContext.class);
+
+ context.update(IMAGES)
+ .set(IMAGES.IMAGE_SOURCE, this.savedData.savedOldURI)
+ .where(IMAGES.IMAGE_ID.eq(this.savedData.savedImageId))
+ .execute();
+
+ model.setImagesAll(LCommandModelUpdates.listImages(context));
+ }
+
+ @Override
+ protected void onRedo(
+ final LFileModel model,
+ final LDatabaseTransactionType transaction)
+ {
+ final var context =
+ transaction.get(DSLContext.class);
+
+ context.update(IMAGES)
+ .set(IMAGES.IMAGE_SOURCE, this.savedData.savedNewURI)
+ .where(IMAGES.IMAGE_ID.eq(this.savedData.savedImageId))
+ .execute();
+
+ model.setImagesAll(LCommandModelUpdates.listImages(context));
+ }
+
+ @Override
+ public Properties toProperties()
+ {
+ final var p = new Properties();
+ p.setProperty("image", Long.toUnsignedString(this.savedData.savedImageId));
+ p.setProperty("oldURI", this.savedData.savedOldURI);
+ p.setProperty("newURI", this.savedData.savedNewURI);
+ return p;
+ }
+
+ @Override
+ public String describe()
+ {
+ return "Set image source";
+ }
+}
diff --git a/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/internal/LFileModel.java b/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/internal/LFileModel.java
index 6e3d87b..5b5c7dd 100644
--- a/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/internal/LFileModel.java
+++ b/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/internal/LFileModel.java
@@ -1225,6 +1225,20 @@ public void exportClear()
this.exportEvents.set(List.of());
}
+ @Override
+ public CompletableFuture> imageSourceSet(
+ final LImageID image,
+ final URI source)
+ {
+ Objects.requireNonNull(image, "image");
+ Objects.requireNonNull(source, "source");
+
+ return this.runCommand(
+ new LCommandImageSourceSet(),
+ new LImageSourceSet(image, source)
+ );
+ }
+
private Optional executeImageStream(
final LImageID id)
throws LException
diff --git a/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/internal/LImageSourceSet.java b/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/internal/LImageSourceSet.java
new file mode 100644
index 0000000..c994dcf
--- /dev/null
+++ b/com.io7m.laurel.filemodel/src/main/java/com/io7m/laurel/filemodel/internal/LImageSourceSet.java
@@ -0,0 +1,48 @@
+/*
+ * 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.LImageID;
+
+import java.net.URI;
+import java.util.Objects;
+
+/**
+ * A request to set a source for an image.
+ *
+ * @param image The image
+ * @param source The source
+ */
+
+public record LImageSourceSet(
+ LImageID image,
+ URI source)
+{
+ /**
+ * A request to set a source for an image.
+ *
+ * @param image The image
+ * @param source The source
+ */
+
+ public LImageSourceSet
+ {
+ Objects.requireNonNull(image, "image");
+ Objects.requireNonNull(source, "source");
+ }
+}
diff --git a/com.io7m.laurel.filemodel/src/main/java/module-info.java b/com.io7m.laurel.filemodel/src/main/java/module-info.java
index 82fe64d..aa17563 100644
--- a/com.io7m.laurel.filemodel/src/main/java/module-info.java
+++ b/com.io7m.laurel.filemodel/src/main/java/module-info.java
@@ -31,6 +31,7 @@
import com.io7m.laurel.filemodel.internal.LCommandImageCaptionsAssign;
import com.io7m.laurel.filemodel.internal.LCommandImageCaptionsUnassign;
import com.io7m.laurel.filemodel.internal.LCommandImageSelect;
+import com.io7m.laurel.filemodel.internal.LCommandImageSourceSet;
import com.io7m.laurel.filemodel.internal.LCommandImagesAdd;
import com.io7m.laurel.filemodel.internal.LCommandImagesDelete;
import com.io7m.laurel.filemodel.internal.LCommandMetadataPut;
@@ -85,6 +86,7 @@
LCommandImageCaptionsAssign,
LCommandImageCaptionsUnassign,
LCommandImageSelect,
+ LCommandImageSourceSet,
LCommandImagesAdd,
LCommandImagesDelete,
LCommandMetadataPut,
diff --git a/com.io7m.laurel.gui/src/main/java/com/io7m/laurel/gui/internal/LCaptionsView.java b/com.io7m.laurel.gui/src/main/java/com/io7m/laurel/gui/internal/LCaptionsView.java
index 0ffb47e..3ce9c09 100644
--- a/com.io7m.laurel.gui/src/main/java/com/io7m/laurel/gui/internal/LCaptionsView.java
+++ b/com.io7m.laurel.gui/src/main/java/com/io7m/laurel/gui/internal/LCaptionsView.java
@@ -44,6 +44,7 @@
import javafx.scene.control.TableColumn;
import javafx.scene.control.TableView;
import javafx.scene.control.TextField;
+import javafx.scene.control.TextInputDialog;
import javafx.scene.image.ImageView;
import javafx.scene.layout.Pane;
import javafx.stage.Stage;
@@ -52,6 +53,7 @@
import java.io.IOException;
import java.io.UncheckedIOException;
+import java.net.URI;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@@ -59,6 +61,7 @@
import java.util.Set;
import java.util.stream.Collectors;
+import static com.io7m.laurel.gui.internal.LStringConstants.IMAGES_SOURCE_SET;
import static javafx.stage.Modality.APPLICATION_MODAL;
/**
@@ -96,6 +99,8 @@ public final class LCaptionsView extends LAbstractViewWithModel
@FXML private MenuItem assignedCaptionsContextMenuCopy;
@FXML private MenuItem assignedCaptionsContextMenuPaste;
@FXML private MenuItem imagesCompareCaptions;
+ @FXML private TextField imageSource;
+ @FXML private Button imageSourceButton;
private Stage imageDisplayWindow;
private LImageView imageDisplay;
@@ -145,6 +150,7 @@ protected void onInitialize()
this.imageDelete.setDisable(true);
this.imageCaptionAssign.setDisable(true);
this.imageCaptionUnassign.setDisable(true);
+ this.imageSourceButton.setDisable(true);
this.captionNew.setDisable(false);
this.captionDelete.setDisable(true);
this.captionModify.setDisable(true);
@@ -413,10 +419,19 @@ private void onImageSelected(
fileModel.imageSelect(Optional.empty());
this.imageView.setImage(null);
this.imageDelete.setDisable(true);
+ this.imageSourceButton.setDisable(true);
+ this.imageSource.setText("");
return;
}
this.imageDelete.setDisable(false);
+ this.imageSourceButton.setDisable(false);
+ this.imageSource.setText(
+ image.image()
+ .source()
+ .map(URI::toString)
+ .orElse("")
+ );
fileModel.imageSelect(Optional.of(image.id()));
fileModel.imageStream(image.id())
@@ -723,4 +738,24 @@ private void onCaptionsCompareSelected()
this.comparisons.open(this.services, this.fileModelScope());
}
+
+ @FXML
+ private void onImageSetSourceSelected()
+ {
+ final var dialog = new TextInputDialog();
+ LCSS.setCSS(dialog.getDialogPane());
+
+ dialog.getEditor().setText(this.imageSource.getText());
+ dialog.setHeaderText(this.strings.format(IMAGES_SOURCE_SET));
+
+ final var r = dialog.showAndWait();
+ if (r.isPresent()) {
+ final var text = URI.create(r.get());
+ final var fileModel = this.fileModelNow();
+ fileModel.imageSourceSet(
+ fileModel.imageSelected().get().orElseThrow().id(),
+ text
+ );
+ }
+ }
}
diff --git a/com.io7m.laurel.gui/src/main/resources/com/io7m/laurel/gui/internal/Messages.properties b/com.io7m.laurel.gui/src/main/resources/com/io7m/laurel/gui/internal/Messages.properties
index ed34e41..5e2ab29 100644
--- a/com.io7m.laurel.gui/src/main/resources/com/io7m/laurel/gui/internal/Messages.properties
+++ b/com.io7m.laurel.gui/src/main/resources/com/io7m/laurel/gui/internal/Messages.properties
@@ -32,6 +32,7 @@ captions.compare.no_extra_captions_present=No captions are present in this\nimag
captions.compare.presenceA=The captions that are on image A but not image B.
captions.compare.presenceB=The captions that are on image B but not image A.
captions.compare=Compare captions
+captions.source=Image source URI
captions.tooltip.add=Add new caption.
captions.tooltip.add_to_image=Add the selected caption to the current image.
captions.tooltip.delete=Delete the selected caption.
@@ -40,6 +41,7 @@ captions.tooltip.modify=Modify the selected caption.
captions.tooltip.priority_down=Reduce caption priority.
captions.tooltip.priority_up=Increase caption priority.
captions.tooltip.remove_from_image=Remove the selected caption from the current image.
+captions.tooltip.source.set=Set image source URI...
captions.unassigned=Unassigned
categories.tooltip.add=Add a new category.
categories.tooltip.assign=Assign captions to the category.
@@ -65,6 +67,7 @@ globals=Global prefix captions
history.compact_confirm=Are you sure you want to delete the undo/redo history and compact the file? This operation cannot be undone.
history.tooltip.compact=Delete history and compact database...
history=History
+images.source.set=Set a new source URI.
image=Image
images.compare_captions=Compare captions...
images.filename=Filename
@@ -100,8 +103,8 @@ title.unsaved=Laurel ({0}) *
title=Laurel
undo=Undo
undo_specific=Undo ({0})
-validation=Validation
+validation.good=Dataset successfully validated without any issues.
validation.tooltip.execute=Run validation now.
validation.tooltip.goto=Go to the offending image.
-validation.good=Dataset successfully validated without any issues.
+validation=Validation
diff --git a/com.io7m.laurel.gui/src/main/resources/com/io7m/laurel/gui/internal/captions.fxml b/com.io7m.laurel.gui/src/main/resources/com/io7m/laurel/gui/internal/captions.fxml
index 203c9d4..f1c507a 100644
--- a/com.io7m.laurel.gui/src/main/resources/com/io7m/laurel/gui/internal/captions.fxml
+++ b/com.io7m.laurel.gui/src/main/resources/com/io7m/laurel/gui/internal/captions.fxml
@@ -31,6 +31,7 @@
+
@@ -113,6 +114,23 @@
+
+
+
+
+
+
diff --git a/com.io7m.laurel.gui/src/main/resources/com/io7m/laurel/gui/internal/image-source-set.png b/com.io7m.laurel.gui/src/main/resources/com/io7m/laurel/gui/internal/image-source-set.png
new file mode 100644
index 0000000..1e401cf
Binary files /dev/null and b/com.io7m.laurel.gui/src/main/resources/com/io7m/laurel/gui/internal/image-source-set.png differ
diff --git a/com.io7m.laurel.tests/src/main/java/com/io7m/laurel/tests/LFileModelTest.java b/com.io7m.laurel.tests/src/main/java/com/io7m/laurel/tests/LFileModelTest.java
index 35db436..2e7b7ad 100644
--- a/com.io7m.laurel.tests/src/main/java/com/io7m/laurel/tests/LFileModelTest.java
+++ b/com.io7m.laurel.tests/src/main/java/com/io7m/laurel/tests/LFileModelTest.java
@@ -44,6 +44,7 @@
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import java.net.URI;
import java.nio.file.Files;
import java.nio.file.OpenOption;
import java.nio.file.Path;
@@ -1549,6 +1550,80 @@ public void testGlobalCaptionsModifyNonexistent()
assertEquals(tx0, tx1);
}
+ @Test
+ public void testImageSourceSet()
+ throws Exception
+ {
+ this.model.imageAdd(
+ "image-a",
+ this.imageFile,
+ Optional.of(this.imageFile.toUri())
+ ).get(TIMEOUT, SECONDS);
+
+ this.model.imageAdd(
+ "image-b",
+ this.imageFile,
+ Optional.of(this.imageFile.toUri())
+ ).get(TIMEOUT, SECONDS);
+
+ this.model.imageAdd(
+ "image-c",
+ this.imageFile,
+ Optional.of(this.imageFile.toUri())
+ ).get(TIMEOUT, SECONDS);
+
+ final var imagesThen = this.model.imageList().get();
+ final var i0 = imagesThen.get(0).id();
+ final var i1 = imagesThen.get(1).id();
+ final var i2 = imagesThen.get(2).id();
+
+ this.model.imageSourceSet(i1, URI.create("urn:example"))
+ .get(TIMEOUT, SECONDS);
+
+ assertEquals(
+ imagesThen.get(0),
+ this.model.imageList().get().get(0)
+ );
+ assertEquals(
+ URI.create("urn:example"),
+ this.model.imageList().get().get(1).image().source().get()
+ );
+ assertEquals(
+ imagesThen.get(2),
+ this.model.imageList().get().get(2)
+ );
+
+ this.model.undo().get(TIMEOUT, SECONDS);
+
+ assertEquals(
+ imagesThen.get(0),
+ this.model.imageList().get().get(0)
+ );
+ assertEquals(
+ imagesThen.get(1),
+ this.model.imageList().get().get(1)
+ );
+ assertEquals(
+ imagesThen.get(2),
+ this.model.imageList().get().get(2)
+ );
+
+ this.model.redo().get(TIMEOUT, SECONDS);
+
+ assertEquals(
+ imagesThen.get(0),
+ this.model.imageList().get().get(0)
+ );
+ assertEquals(
+ URI.create("urn:example"),
+ this.model.imageList().get().get(1).image().source().get()
+ );
+ assertEquals(
+ imagesThen.get(2),
+ this.model.imageList().get().get(2)
+ );
+ }
+
private List globalCaptionsNow()
{
return this.model.globalCaptionList()