From 6306f7ded457006b976fe00feb09d982b24ac823 Mon Sep 17 00:00:00 2001 From: Alan Richardson Date: Thu, 16 Jul 2026 16:48:15 +0100 Subject: [PATCH] Add file-backed SQLite CRUD UI projects --- .../repository/ThingStoreProviderConfig.java | 35 +- .../sqlite/SqliteThingStoreProvider.java | 35 ++ .../repository/ThingStoreContractTest.java | 29 + .../ThingStoreProviderConfigTest.java | 16 + thingifier-crud-ui/pom.xml | 68 +++ .../crudui/ActiveThingifierWorkspace.java | 91 ++- .../thingifier/crudui/CrudUiArguments.java | 19 +- .../thingifier/crudui/CrudUiController.java | 73 +++ .../thingifier/crudui/CrudUiMain.java | 2 +- .../crudui/ProjectActionRequest.java | 57 ++ .../thingifier/crudui/ProjectPathChooser.java | 6 + .../crudui/ProjectPathSelection.java | 52 ++ .../crudui/SwingProjectPathChooser.java | 88 +++ .../crudui/WorkspaceDataImporter.java | 30 +- .../crudui/WorkspaceMetadataJson.java | 9 + .../crudui/WorkspaceProjectService.java | 530 +++++++++++++++++- .../crudui/WorkspaceSchemaUpgradeService.java | 29 +- .../thingifier/crudui/WorkspaceSnapshot.java | 9 +- .../thingifier/crudui/WorkspaceStorage.java | 105 ++++ .../adapter/spark/CrudUiApplication.java | 19 + .../src/main/resources/public/assets/app.js | 403 ++++++++++++- .../main/resources/public/assets/styles.css | 77 +++ .../src/main/resources/public/index.html | 98 +++- .../src/main/resources/public/schema.html | 90 +-- .../crudui/ActiveThingifierWorkspaceTest.java | 83 +++ .../crudui/CrudUiArgumentsTest.java | 14 + .../crudui/CrudUiControllerTest.java | 128 ++++- .../crudui/WorkspaceProjectServiceTest.java | 410 ++++++++++++++ .../crudui/e2e/BrowserFolderPickerStub.java | 66 +++ .../crudui/e2e/BrowserTestBase.java | 138 +++++ .../crudui/e2e/CrudUiApiClient.java | 120 ++++ .../thingifier/crudui/e2e/CrudUiApiIT.java | 188 +++++++ .../crudui/e2e/CrudUiTestServer.java | 57 ++ .../thingifier/crudui/e2e/E2eResource.java | 21 + .../crudui/e2e/ProjectStorageUiIT.java | 161 ++++++ .../crudui/e2e/RelationshipUiIT.java | 61 ++ .../crudui/e2e/SchemaApplyUiIT.java | 62 ++ .../crudui/e2e/SchemaEditorUiIT.java | 121 ++++ .../thingifier/crudui/e2e/WorkspaceUiIT.java | 136 +++++ .../e2e/components/DataGridComponent.java | 41 ++ .../e2e/components/DiagramPanelComponent.java | 45 ++ .../e2e/components/EditorPanelComponent.java | 42 ++ .../e2e/components/MessageBarComponent.java | 17 + .../e2e/components/OutlineTreeComponent.java | 61 ++ .../components/ProjectDialogComponent.java | 69 +++ .../RelationshipManagerComponent.java | 41 ++ .../e2e/components/SchemaDetailComponent.java | 39 ++ .../components/SchemaExportsComponent.java | 43 ++ .../e2e/components/SchemaTreeComponent.java | 46 ++ .../SchemaUpgradeDialogComponent.java | 37 ++ .../components/StorageControlsComponent.java | 32 ++ .../crudui/e2e/components/TestSelectors.java | 25 + .../e2e/components/TopBarComponent.java | 43 ++ .../crudui/e2e/pages/SchemaEditPage.java | 79 +++ .../crudui/e2e/pages/WorkspacePage.java | 78 +++ .../test/resources/models/todo-manager.yaml | 83 +++ .../yaml/ThingifierProjectManifest.java | 106 +++- .../yaml/ThingifierProjectManifestYaml.java | 17 +- .../thingifier/yaml/ThingifierYamlLoader.java | 23 +- .../ThingifierProjectManifestYamlTest.java | 55 ++ .../yaml/ThingifierYamlLoaderTest.java | 14 + .../definition/ThingifierModelAssembler.java | 12 +- 62 files changed, 4662 insertions(+), 122 deletions(-) create mode 100644 thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/ProjectActionRequest.java create mode 100644 thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/ProjectPathChooser.java create mode 100644 thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/ProjectPathSelection.java create mode 100644 thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/SwingProjectPathChooser.java create mode 100644 thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/WorkspaceStorage.java create mode 100644 thingifier-crud-ui/src/test/java/uk/co/compendiumdev/thingifier/crudui/e2e/BrowserFolderPickerStub.java create mode 100644 thingifier-crud-ui/src/test/java/uk/co/compendiumdev/thingifier/crudui/e2e/BrowserTestBase.java create mode 100644 thingifier-crud-ui/src/test/java/uk/co/compendiumdev/thingifier/crudui/e2e/CrudUiApiClient.java create mode 100644 thingifier-crud-ui/src/test/java/uk/co/compendiumdev/thingifier/crudui/e2e/CrudUiApiIT.java create mode 100644 thingifier-crud-ui/src/test/java/uk/co/compendiumdev/thingifier/crudui/e2e/CrudUiTestServer.java create mode 100644 thingifier-crud-ui/src/test/java/uk/co/compendiumdev/thingifier/crudui/e2e/E2eResource.java create mode 100644 thingifier-crud-ui/src/test/java/uk/co/compendiumdev/thingifier/crudui/e2e/ProjectStorageUiIT.java create mode 100644 thingifier-crud-ui/src/test/java/uk/co/compendiumdev/thingifier/crudui/e2e/RelationshipUiIT.java create mode 100644 thingifier-crud-ui/src/test/java/uk/co/compendiumdev/thingifier/crudui/e2e/SchemaApplyUiIT.java create mode 100644 thingifier-crud-ui/src/test/java/uk/co/compendiumdev/thingifier/crudui/e2e/SchemaEditorUiIT.java create mode 100644 thingifier-crud-ui/src/test/java/uk/co/compendiumdev/thingifier/crudui/e2e/WorkspaceUiIT.java create mode 100644 thingifier-crud-ui/src/test/java/uk/co/compendiumdev/thingifier/crudui/e2e/components/DataGridComponent.java create mode 100644 thingifier-crud-ui/src/test/java/uk/co/compendiumdev/thingifier/crudui/e2e/components/DiagramPanelComponent.java create mode 100644 thingifier-crud-ui/src/test/java/uk/co/compendiumdev/thingifier/crudui/e2e/components/EditorPanelComponent.java create mode 100644 thingifier-crud-ui/src/test/java/uk/co/compendiumdev/thingifier/crudui/e2e/components/MessageBarComponent.java create mode 100644 thingifier-crud-ui/src/test/java/uk/co/compendiumdev/thingifier/crudui/e2e/components/OutlineTreeComponent.java create mode 100644 thingifier-crud-ui/src/test/java/uk/co/compendiumdev/thingifier/crudui/e2e/components/ProjectDialogComponent.java create mode 100644 thingifier-crud-ui/src/test/java/uk/co/compendiumdev/thingifier/crudui/e2e/components/RelationshipManagerComponent.java create mode 100644 thingifier-crud-ui/src/test/java/uk/co/compendiumdev/thingifier/crudui/e2e/components/SchemaDetailComponent.java create mode 100644 thingifier-crud-ui/src/test/java/uk/co/compendiumdev/thingifier/crudui/e2e/components/SchemaExportsComponent.java create mode 100644 thingifier-crud-ui/src/test/java/uk/co/compendiumdev/thingifier/crudui/e2e/components/SchemaTreeComponent.java create mode 100644 thingifier-crud-ui/src/test/java/uk/co/compendiumdev/thingifier/crudui/e2e/components/SchemaUpgradeDialogComponent.java create mode 100644 thingifier-crud-ui/src/test/java/uk/co/compendiumdev/thingifier/crudui/e2e/components/StorageControlsComponent.java create mode 100644 thingifier-crud-ui/src/test/java/uk/co/compendiumdev/thingifier/crudui/e2e/components/TestSelectors.java create mode 100644 thingifier-crud-ui/src/test/java/uk/co/compendiumdev/thingifier/crudui/e2e/components/TopBarComponent.java create mode 100644 thingifier-crud-ui/src/test/java/uk/co/compendiumdev/thingifier/crudui/e2e/pages/SchemaEditPage.java create mode 100644 thingifier-crud-ui/src/test/java/uk/co/compendiumdev/thingifier/crudui/e2e/pages/WorkspacePage.java create mode 100644 thingifier-crud-ui/src/test/resources/models/todo-manager.yaml diff --git a/ercoremodel/src/main/java/uk/co/compendiumdev/thingifier/core/repository/ThingStoreProviderConfig.java b/ercoremodel/src/main/java/uk/co/compendiumdev/thingifier/core/repository/ThingStoreProviderConfig.java index 6e1f9bb3..50cccbb7 100644 --- a/ercoremodel/src/main/java/uk/co/compendiumdev/thingifier/core/repository/ThingStoreProviderConfig.java +++ b/ercoremodel/src/main/java/uk/co/compendiumdev/thingifier/core/repository/ThingStoreProviderConfig.java @@ -10,18 +10,28 @@ public class ThingStoreProviderConfig { public static final String DEFAULT_REPOSITORY_MODE = "memory"; public static final String ARG_REPOSITORY_MODE = "-thingifier-repository"; public static final String ARG_SQLITE_DIRECTORY = "-thingifier-sqlite-directory"; + public static final String ARG_SQLITE_FILE = "-thingifier-sqlite-file"; public static final String ARG_SQLITE_MEMORY = "-sqlite-memory"; public static final String ENV_REPOSITORY_MODE = "THINGIFIER_REPOSITORY"; public static final String ENV_SQLITE_DIRECTORY = "THINGIFIER_SQLITE_DIRECTORY"; + public static final String ENV_SQLITE_FILE = "THINGIFIER_SQLITE_FILE"; public static final String PROPERTY_REPOSITORY_MODE = "thingifier.repository"; public static final String PROPERTY_SQLITE_DIRECTORY = "thingifier.sqlite.directory"; + public static final String PROPERTY_SQLITE_FILE = "thingifier.sqlite.file"; private final String repositoryMode; private final Path sqliteDirectory; + private final Path sqliteFile; public ThingStoreProviderConfig(final String repositoryMode, final Path sqliteDirectory) { + this(repositoryMode, sqliteDirectory, null); + } + + public ThingStoreProviderConfig( + final String repositoryMode, final Path sqliteDirectory, final Path sqliteFile) { this.repositoryMode = normalize(repositoryMode); this.sqliteDirectory = sqliteDirectory; + this.sqliteFile = sqliteFile; } public static ThingStoreProviderConfig fromArgs(final String[] args) { @@ -44,7 +54,16 @@ public static ThingStoreProviderConfig fromArgs(final String[] args) { System.getenv(ENV_SQLITE_DIRECTORY), "thingifier-sqlite"); - return new ThingStoreProviderConfig(repositoryMode, Paths.get(sqliteDirectory)); + String sqliteFile = + firstNonBlank( + argValue(args, ARG_SQLITE_FILE), + System.getProperty(PROPERTY_SQLITE_FILE), + System.getenv(ENV_SQLITE_FILE)); + + return new ThingStoreProviderConfig( + repositoryMode, + Paths.get(sqliteDirectory), + sqliteFile.isEmpty() ? null : Paths.get(sqliteFile)); } public ThingStoreProvider createProvider() { @@ -60,6 +79,9 @@ public ThingStoreProvider createProvider() { case "sqlite-file": case "sqlite-disk": case "file": + if (sqliteFile != null) { + return SqliteThingStoreProvider.fileBackedFile(sqliteFile); + } return SqliteThingStoreProvider.fileBacked(sqliteDirectory); default: throw new IllegalArgumentException( @@ -77,10 +99,21 @@ public Path getSqliteDirectory() { return sqliteDirectory; } + public Path getSqliteFile() { + return sqliteFile; + } + + public boolean hasSqliteFile() { + return sqliteFile != null; + } + public String describe() { if (repositoryMode.equals("sqlite-file") || repositoryMode.equals("sqlite-disk") || repositoryMode.equals("file")) { + if (sqliteFile != null) { + return repositoryMode + " at " + sqliteFile.toAbsolutePath(); + } return repositoryMode + " at " + sqliteDirectory.toAbsolutePath(); } return repositoryMode; diff --git a/ercoremodel/src/main/java/uk/co/compendiumdev/thingifier/core/repository/sqlite/SqliteThingStoreProvider.java b/ercoremodel/src/main/java/uk/co/compendiumdev/thingifier/core/repository/sqlite/SqliteThingStoreProvider.java index db37f50c..10edf351 100644 --- a/ercoremodel/src/main/java/uk/co/compendiumdev/thingifier/core/repository/sqlite/SqliteThingStoreProvider.java +++ b/ercoremodel/src/main/java/uk/co/compendiumdev/thingifier/core/repository/sqlite/SqliteThingStoreProvider.java @@ -56,6 +56,27 @@ public static SqliteThingStoreProvider fileBacked(final Path directory) { }); } + public static SqliteThingStoreProvider fileBackedFile(final Path databaseFile) { + final Path normalized = databaseFile.toAbsolutePath().normalize(); + final Path parent = normalized.getParent(); + if (parent != null) { + try { + Files.createDirectories(parent); + } catch (IOException e) { + throw new IllegalStateException( + "Could not create SQLite repository directory " + parent, e); + } + } + + return new SqliteThingStoreProvider( + databaseKey -> + "jdbc:sqlite:" + + databaseFileFor(normalized, databaseKey) + .toAbsolutePath() + .toString() + .replace("\\", "/")); + } + @Override public ThingStore getDefaultStore() { return repositories.get(EntityRelModel.DEFAULT_DATABASE_NAME); @@ -106,4 +127,18 @@ public void deleteStore(final String databaseKey) { private static String safeName(final String databaseKey) { return databaseKey.replaceAll("[^A-Za-z0-9._-]", "_"); } + + private static Path databaseFileFor(final Path databaseFile, final String databaseKey) { + if (EntityRelModel.DEFAULT_DATABASE_NAME.equals(databaseKey)) { + return databaseFile; + } + + final Path parent = databaseFile.getParent(); + final String fileName = databaseFile.getFileName().toString(); + final int extensionAt = fileName.lastIndexOf("."); + final String baseName = extensionAt > 0 ? fileName.substring(0, extensionAt) : fileName; + final String extension = extensionAt > 0 ? fileName.substring(extensionAt) : ".sqlite"; + final String sessionFileName = baseName + "-" + safeName(databaseKey) + extension; + return parent == null ? Path.of(sessionFileName) : parent.resolve(sessionFileName); + } } diff --git a/ercoremodel/src/test/java/uk/co/compendiumdev/thingifier/core/repository/ThingStoreContractTest.java b/ercoremodel/src/test/java/uk/co/compendiumdev/thingifier/core/repository/ThingStoreContractTest.java index bc8b5f3a..a3b48f8e 100644 --- a/ercoremodel/src/test/java/uk/co/compendiumdev/thingifier/core/repository/ThingStoreContractTest.java +++ b/ercoremodel/src/test/java/uk/co/compendiumdev/thingifier/core/repository/ThingStoreContractTest.java @@ -1,5 +1,6 @@ package uk.co.compendiumdev.thingifier.core.repository; +import java.nio.file.Files; import java.nio.file.Path; import java.util.List; import java.util.UUID; @@ -58,6 +59,34 @@ public void sqliteProviderKeepsLogicalDatabasesSeparate() { } } + @Test + public void directSqliteFileProviderCreatesAndReopensDatabaseFile() { + ERSchema schema = todoSchema(); + Path databasePath = tempDir.resolve("direct-provider.sqlite"); + + try (ThingStoreProvider provider = SqliteThingStoreProvider.fileBackedFile(databasePath)) { + ThingStore store = provider.getDefaultStore(); + store.administration().initializeFrom(schema); + create(store, schema.getEntityDefinitionNamed("project"), "File provider"); + } + + Assertions.assertTrue(Files.exists(databasePath)); + + try (ThingStoreProvider reopened = SqliteThingStoreProvider.fileBackedFile(databasePath)) { + ThingStore store = reopened.getDefaultStore(); + store.administration().initializeFrom(schema); + + Assertions.assertEquals( + 1, store.entityQueries().count(schema.getEntityDefinitionNamed("project"))); + Assertions.assertEquals( + "File provider", + store.entityQueries() + .findByPrimaryKey(schema.getEntityDefinitionNamed("project"), "1") + .getFieldValue("title") + .asString()); + } + } + @Test public void inMemoryTransactionCommitPersistsChanges() { ThingStore repository = new InMemoryThingStore(EntityRelModel.DEFAULT_DATABASE_NAME); diff --git a/ercoremodel/src/test/java/uk/co/compendiumdev/thingifier/core/repository/ThingStoreProviderConfigTest.java b/ercoremodel/src/test/java/uk/co/compendiumdev/thingifier/core/repository/ThingStoreProviderConfigTest.java index 8a7e4733..67a52463 100644 --- a/ercoremodel/src/test/java/uk/co/compendiumdev/thingifier/core/repository/ThingStoreProviderConfigTest.java +++ b/ercoremodel/src/test/java/uk/co/compendiumdev/thingifier/core/repository/ThingStoreProviderConfigTest.java @@ -52,6 +52,22 @@ public void canCreateSqliteFileRepositoryFromArgs() { Assertions.assertTrue(config.createProvider() instanceof SqliteThingStoreProvider); } + @Test + public void canCreateDirectSqliteFileRepositoryFromArgs() { + Path databaseFile = tempDir.resolve("crud-ui.sqlite"); + ThingStoreProviderConfig config = + ThingStoreProviderConfig.fromArgs( + new String[] { + "-thingifier-repository=sqlite-file", + "-thingifier-sqlite-file=" + databaseFile + }); + + Assertions.assertEquals("sqlite-file", config.getRepositoryMode()); + Assertions.assertTrue(config.hasSqliteFile()); + Assertions.assertEquals(databaseFile, config.getSqliteFile()); + Assertions.assertTrue(config.createProvider() instanceof SqliteThingStoreProvider); + } + @Test public void rejectsUnknownRepositoryModes() { ThingStoreProviderConfig config = diff --git a/thingifier-crud-ui/pom.xml b/thingifier-crud-ui/pom.xml index be6cf2ff..8cdfd001 100644 --- a/thingifier-crud-ui/pom.xml +++ b/thingifier-crud-ui/pom.xml @@ -16,6 +16,12 @@ thingifier crud ui https://compendiumdev.co.uk + + 1.61.0 + true + false + + uk.co.compendiumdev @@ -54,6 +60,12 @@ ${junit.jupiter.version} test + + com.microsoft.playwright + playwright + ${playwright.version} + test + @@ -72,6 +84,62 @@ maven-surefire-plugin 3.0.0-M4 + + org.codehaus.mojo + exec-maven-plugin + 3.5.0 + + + install-playwright-chromium + pre-integration-test + + exec + + + ${skipITs} + ${java.home}/bin/java + test + + -cp + + com.microsoft.playwright.CLI + install + chromium + + + + + + + org.apache.maven.plugins + maven-failsafe-plugin + 3.0.0-M4 + + ${skipITs} + + ${crud.ui.headless} + + + **/*IT.java + + + + + integration-tests + integration-test + + integration-test + + + + verify-integration-tests + verify + + verify + + + + maven-assembly-plugin diff --git a/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/ActiveThingifierWorkspace.java b/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/ActiveThingifierWorkspace.java index fd7d53bf..a63c4515 100644 --- a/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/ActiveThingifierWorkspace.java +++ b/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/ActiveThingifierWorkspace.java @@ -6,6 +6,7 @@ import uk.co.compendiumdev.thingifier.Thingifier; import uk.co.compendiumdev.thingifier.apiconfig.ThingifierApiConfigProfile; import uk.co.compendiumdev.thingifier.application.examples.TodoManagerThingifier; +import uk.co.compendiumdev.thingifier.application.schema.definition.ThingifierModelAssembler; import uk.co.compendiumdev.thingifier.application.schema.definition.ThingifierModelDefinition; import uk.co.compendiumdev.thingifier.application.schema.definition.ThingifierModelExporter; import uk.co.compendiumdev.thingifier.yaml.ThingifierYamlExporter; @@ -19,15 +20,21 @@ public final class ActiveThingifierWorkspace implements AutoCloseable { private Thingifier thingifier; private ThingifierModelDefinition definition; private String schemaYaml; + private WorkspaceStorage storage; private String projectPath; private String projectTitle; private String projectDescription; private long version; private ActiveThingifierWorkspace(final Thingifier thingifier) { + this(thingifier, WorkspaceStorage.memory()); + } + + private ActiveThingifierWorkspace(final Thingifier thingifier, final WorkspaceStorage storage) { modelExporter = new ThingifierModelExporter(); yamlExporter = new ThingifierYamlExporter(); yamlLoader = new ThingifierYamlLoader(); + this.storage = storage; replaceWith(thingifier); version = 1L; } @@ -41,23 +48,39 @@ public static ActiveThingifierWorkspace defaultTodoManagerWorkspace() { return new ActiveThingifierWorkspace(todoManager); } + public static ActiveThingifierWorkspace defaultTodoManagerWorkspace( + final WorkspaceStorage storage) { + ActiveThingifierWorkspace workspace = defaultTodoManagerWorkspace(); + if (!WorkspaceStorage.MODE_MEMORY.equals(storage.mode())) { + workspace.switchStorage(storage); + } + return workspace; + } + static ActiveThingifierWorkspace forThingifier(final Thingifier thingifier) { return new ActiveThingifierWorkspace(thingifier); } + static ActiveThingifierWorkspace forThingifier( + final Thingifier thingifier, final WorkspaceStorage storage) { + return new ActiveThingifierWorkspace(thingifier, storage); + } + public synchronized WorkspaceSnapshot snapshot() { return new WorkspaceSnapshot( version, thingifier, definition, schemaYaml, + storage, projectPath, projectTitle, projectDescription); } public synchronized WorkspaceSnapshot replaceWithYaml(final String yamlText) { - Thingifier newThingifier = yamlLoader.loadThingifier(yamlText); + Thingifier newThingifier = yamlLoader.loadThingifier(yamlText, storage.provider()); + newThingifier.clearAllData(); Thingifier oldThingifier = thingifier; replaceWith(newThingifier); clearProject(); @@ -74,11 +97,19 @@ public synchronized WorkspaceSnapshot replaceWithYaml(final Path path) throws IO public synchronized WorkspaceSnapshot replaceWithMigratedThingifier( final Thingifier newThingifier, final long expectedVersion) { + return replaceWithMigratedThingifier(newThingifier, storage, expectedVersion); + } + + public synchronized WorkspaceSnapshot replaceWithMigratedThingifier( + final Thingifier newThingifier, + final WorkspaceStorage newStorage, + final long expectedVersion) { if (version != expectedVersion) { throw new IllegalStateException( "Workspace changed; refresh schema preview before applying"); } Thingifier oldThingifier = thingifier; + storage = newStorage; replaceWith(newThingifier); version++; if (oldThingifier != null) { @@ -89,12 +120,28 @@ public synchronized WorkspaceSnapshot replaceWithMigratedThingifier( synchronized WorkspaceSnapshot replaceWithProjectThingifier( final Thingifier newThingifier, + final WorkspaceStorage newStorage, final Path projectFolder, final String title, final String description) { + return replaceWithProjectThingifier( + newThingifier, + newStorage, + projectFolder.toAbsolutePath().normalize().toString(), + title, + description); + } + + synchronized WorkspaceSnapshot replaceWithProjectThingifier( + final Thingifier newThingifier, + final WorkspaceStorage newStorage, + final String projectPathDisplay, + final String title, + final String description) { Thingifier oldThingifier = thingifier; + storage = newStorage; replaceWith(newThingifier); - projectPath = projectFolder.toAbsolutePath().normalize().toString(); + projectPath = nullToEmpty(projectPathDisplay); projectTitle = nullToEmpty(title); projectDescription = nullToEmpty(description); version++; @@ -104,6 +151,41 @@ synchronized WorkspaceSnapshot replaceWithProjectThingifier( return snapshot(); } + public synchronized WorkspaceSnapshot switchStorage(final WorkspaceStorage newStorage) { + if (sameStorage(newStorage)) { + return snapshot(); + } + + final String dataJson = + new WorkspaceDataExporter(this, new DynamicThingifierApiProxy(this)) + .exportProjectDataJson(); + ActiveThingifierWorkspace staging = null; + try { + Thingifier stagedThingifier = + new ThingifierModelAssembler().assemble(definition, newStorage.provider()); + stagedThingifier.clearAllData(); + staging = ActiveThingifierWorkspace.forThingifier(stagedThingifier, newStorage); + new WorkspaceDataImporter( + staging, + new DynamicThingifierApiProxy(staging), + new WorkspaceMetadataJson()) + .importDataIntoCurrentWorkspace(dataJson); + Thingifier released = staging.releaseThingifier(); + Thingifier oldThingifier = thingifier; + storage = newStorage; + replaceWith(released); + version++; + if (oldThingifier != null) { + oldThingifier.close(); + } + return snapshot(); + } finally { + if (staging != null) { + staging.close(); + } + } + } + synchronized WorkspaceSnapshot markProjectSaved( final Path projectFolder, final String title, final String description) { projectPath = projectFolder.toAbsolutePath().normalize().toString(); @@ -130,6 +212,11 @@ private void clearProject() { projectDescription = ""; } + private boolean sameStorage(final WorkspaceStorage other) { + return storage.mode().equals(other.mode()) + && storage.sqliteFilePath().equals(other.sqliteFilePath()); + } + private String nullToEmpty(final String value) { return value == null ? "" : value; } diff --git a/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/CrudUiArguments.java b/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/CrudUiArguments.java index 654816bc..3e5f6f41 100644 --- a/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/CrudUiArguments.java +++ b/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/CrudUiArguments.java @@ -2,17 +2,24 @@ import java.nio.file.Path; import java.nio.file.Paths; +import uk.co.compendiumdev.thingifier.core.repository.ThingStoreProviderConfig; public final class CrudUiArguments { private final int port; private final Path modelYamlPath; private final Path projectPath; + private final WorkspaceStorage storage; - private CrudUiArguments(final int port, final Path modelYamlPath, final Path projectPath) { + private CrudUiArguments( + final int port, + final Path modelYamlPath, + final Path projectPath, + final WorkspaceStorage storage) { this.port = port; this.modelYamlPath = modelYamlPath; this.projectPath = projectPath; + this.storage = storage; } public static CrudUiArguments parse(final String[] args) { @@ -38,7 +45,11 @@ public static CrudUiArguments parse(final String[] args) { throw new IllegalArgumentException("Use either -project or -modelYaml, not both"); } - return new CrudUiArguments(configuredPort, configuredModelYamlPath, configuredProjectPath); + return new CrudUiArguments( + configuredPort, + configuredModelYamlPath, + configuredProjectPath, + WorkspaceStorage.fromConfig(ThingStoreProviderConfig.fromArgs(args))); } public int port() { @@ -60,4 +71,8 @@ public boolean hasProjectPath() { public Path projectPath() { return projectPath; } + + public WorkspaceStorage storage() { + return storage; + } } diff --git a/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/CrudUiController.java b/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/CrudUiController.java index 6fc1e7f4..1191a8a7 100644 --- a/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/CrudUiController.java +++ b/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/CrudUiController.java @@ -11,11 +11,19 @@ public final class CrudUiController { private final WorkspaceDataExporter exporter; private final WorkspaceDataImporter importer; private final WorkspaceProjectService projectService; + private final ProjectPathChooser projectPathChooser; private final SchemaPreviewService schemaPreviewService; private final WorkspaceSchemaUpgradeService schemaUpgradeService; public CrudUiController(final ActiveThingifierWorkspace workspace) { + this(workspace, new SwingProjectPathChooser()); + } + + CrudUiController( + final ActiveThingifierWorkspace workspace, + final ProjectPathChooser projectPathChooser) { this.workspace = workspace; + this.projectPathChooser = projectPathChooser; DynamicThingifierApiProxy apiProxy = new DynamicThingifierApiProxy(workspace); metadataJson = new WorkspaceMetadataJson(); exporter = new WorkspaceDataExporter(workspace, apiProxy); @@ -106,6 +114,67 @@ public UiHttpResponse loadProject(final String requestJson) { } } + public UiHttpResponse browseProject(final String requestJson) { + try { + ProjectActionRequest request = ProjectActionRequest.fromJson(requestJson, false); + ProjectPathSelection selection = projectPathChooser.choose(request); + if (!selection.isAvailable()) { + return JsonSupport.error(400, selection.message()); + } + return UiHttpResponse.json(200, JsonSupport.toJson(selection.toMap())); + } catch (CrudUiException | IllegalArgumentException e) { + return JsonSupport.error(400, e.getMessage()); + } + } + + public UiHttpResponse checkProject(final String requestJson) { + try { + return projectService.check(requestJson); + } catch (ThingifierYamlException | CrudUiException | IllegalArgumentException e) { + return JsonSupport.error(400, e.getMessage()); + } + } + + public UiHttpResponse exportProjectFiles() { + return exportProjectFiles("{}"); + } + + public UiHttpResponse exportProjectFiles(final String requestJson) { + try { + return projectService.exportFiles(requestJson); + } catch (ThingifierYamlException | CrudUiException | IllegalArgumentException e) { + return JsonSupport.error(400, e.getMessage()); + } + } + + public UiHttpResponse loadProjectFiles(final String requestJson) { + try { + return projectService.loadFiles(requestJson); + } catch (ThingifierYamlException | CrudUiException | IllegalArgumentException e) { + return JsonSupport.error(400, e.getMessage()); + } + } + + public UiHttpResponse switchStorage(final String requestJson) { + try { + Map request = + JsonSupport.fromJsonMap( + requestJson, + "Storage request must contain a JSON object", + "Could not parse storage request JSON"); + WorkspaceStorage storage = + WorkspaceStorage.fromModeAndPath( + stringValue(request.get("mode")), + stringValue(request.get("sqliteFile"))); + WorkspaceSnapshot snapshot = workspace.switchStorage(storage); + Map body = metadataJson.toMap(snapshot); + body.put("storageStatus", "switched"); + return UiHttpResponse.json(200, JsonSupport.toJson(body)); + } catch (CrudUiException | IllegalArgumentException | IllegalStateException e) { + return JsonSupport.error(400, e.getMessage()); + } + } + public UiHttpResponse apiDocumentationPage() { return UiHttpResponse.html(new ApiDocumentationPage(workspace.snapshot()).html()); } @@ -129,4 +198,8 @@ public UiHttpResponse downloadOpenApi(final boolean permissive) { String body = permissive ? openApi.permissiveOpenApiJson() : openApi.openApiJson(); return new UiHttpResponse(200, "application/json", body, headers); } + + private String stringValue(final Object value) { + return value == null ? "" : String.valueOf(value); + } } diff --git a/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/CrudUiMain.java b/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/CrudUiMain.java index af7dd12b..d923afc3 100644 --- a/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/CrudUiMain.java +++ b/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/CrudUiMain.java @@ -11,7 +11,7 @@ private CrudUiMain() {} public static void main(final String[] args) throws IOException { CrudUiArguments arguments = CrudUiArguments.parse(args); ActiveThingifierWorkspace workspace = - ActiveThingifierWorkspace.defaultTodoManagerWorkspace(); + ActiveThingifierWorkspace.defaultTodoManagerWorkspace(arguments.storage()); if (arguments.hasProjectPath()) { new WorkspaceProjectService(workspace, new WorkspaceMetadataJson()) .load(JsonSupport.toJson(Map.of("path", arguments.projectPath().toString()))); diff --git a/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/ProjectActionRequest.java b/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/ProjectActionRequest.java new file mode 100644 index 00000000..90a0cce9 --- /dev/null +++ b/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/ProjectActionRequest.java @@ -0,0 +1,57 @@ +package uk.co.compendiumdev.thingifier.crudui; + +import java.util.Map; + +final class ProjectActionRequest { + + static final String ACTION_LOAD = "load"; + static final String ACTION_SAVE = "save"; + + private final String action; + private final String path; + + private ProjectActionRequest(final String action, final String path) { + this.action = action; + this.path = path; + } + + static ProjectActionRequest fromJson(final String requestJson, final boolean pathRequired) { + Map request = + JsonSupport.fromJsonMap( + requestJson, + "Project request must contain a JSON object", + "Could not parse project request JSON"); + String action = stringValue(request.get("action")).trim(); + if (action.isEmpty()) { + throw new CrudUiException(400, "Project request must contain action"); + } + if (!ACTION_LOAD.equals(action) && !ACTION_SAVE.equals(action)) { + throw new CrudUiException(400, "Project action must be save or load"); + } + String path = stringValue(request.get("path")).trim(); + if (pathRequired && path.isEmpty()) { + throw new CrudUiException(400, "Project request must contain path"); + } + return new ProjectActionRequest(action, path); + } + + String action() { + return action; + } + + String path() { + return path; + } + + boolean isSave() { + return ACTION_SAVE.equals(action); + } + + boolean isLoad() { + return ACTION_LOAD.equals(action); + } + + private static String stringValue(final Object value) { + return value == null ? "" : String.valueOf(value); + } +} diff --git a/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/ProjectPathChooser.java b/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/ProjectPathChooser.java new file mode 100644 index 00000000..36e92b43 --- /dev/null +++ b/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/ProjectPathChooser.java @@ -0,0 +1,6 @@ +package uk.co.compendiumdev.thingifier.crudui; + +interface ProjectPathChooser { + + ProjectPathSelection choose(ProjectActionRequest request); +} diff --git a/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/ProjectPathSelection.java b/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/ProjectPathSelection.java new file mode 100644 index 00000000..09c07906 --- /dev/null +++ b/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/ProjectPathSelection.java @@ -0,0 +1,52 @@ +package uk.co.compendiumdev.thingifier.crudui; + +import java.util.LinkedHashMap; +import java.util.Map; + +final class ProjectPathSelection { + + private final boolean available; + private final boolean selected; + private final String path; + private final String message; + + private ProjectPathSelection( + final boolean available, + final boolean selected, + final String path, + final String message) { + this.available = available; + this.selected = selected; + this.path = path; + this.message = message; + } + + static ProjectPathSelection selected(final String path) { + return new ProjectPathSelection(true, true, path, "Project path selected."); + } + + static ProjectPathSelection cancelled() { + return new ProjectPathSelection(true, false, "", "Project browsing cancelled."); + } + + static ProjectPathSelection unavailable(final String message) { + return new ProjectPathSelection(false, false, "", message); + } + + boolean isAvailable() { + return available; + } + + String message() { + return message; + } + + Map toMap() { + Map body = new LinkedHashMap<>(); + body.put("available", available); + body.put("selected", selected); + body.put("path", path); + body.put("message", message); + return body; + } +} diff --git a/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/SwingProjectPathChooser.java b/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/SwingProjectPathChooser.java new file mode 100644 index 00000000..3e051eb1 --- /dev/null +++ b/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/SwingProjectPathChooser.java @@ -0,0 +1,88 @@ +package uk.co.compendiumdev.thingifier.crudui; + +import java.awt.EventQueue; +import java.awt.GraphicsEnvironment; +import java.awt.Window; +import java.io.File; +import java.lang.reflect.InvocationTargetException; +import java.util.concurrent.atomic.AtomicReference; +import javax.swing.JFileChooser; +import javax.swing.JFrame; + +final class SwingProjectPathChooser implements ProjectPathChooser { + + @Override + public ProjectPathSelection choose(final ProjectActionRequest request) { + if (GraphicsEnvironment.isHeadless()) { + return ProjectPathSelection.unavailable( + "Native project browsing is not available in headless mode."); + } + try { + return chooseOnEventThread(request); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return ProjectPathSelection.unavailable("Native project browsing was interrupted."); + } catch (InvocationTargetException | RuntimeException e) { + return ProjectPathSelection.unavailable( + "Native project browsing is unavailable: " + rootMessage(e)); + } + } + + private ProjectPathSelection chooseOnEventThread(final ProjectActionRequest request) + throws InterruptedException, InvocationTargetException { + if (EventQueue.isDispatchThread()) { + return showChooser(request); + } + AtomicReference selection = new AtomicReference<>(); + EventQueue.invokeAndWait(() -> selection.set(showChooser(request))); + return selection.get(); + } + + private ProjectPathSelection showChooser(final ProjectActionRequest request) { + JFileChooser chooser = new JFileChooser(); + chooser.setDialogTitle(request.isSave() ? "Save Project" : "Load Project"); + chooser.setFileSelectionMode( + request.isSave() + ? JFileChooser.DIRECTORIES_ONLY + : JFileChooser.FILES_AND_DIRECTORIES); + if (!request.path().isEmpty()) { + chooser.setSelectedFile(new File(request.path())); + } + + JFrame owner = foregroundOwner(); + try { + int result = + request.isSave() + ? chooser.showSaveDialog(owner) + : chooser.showOpenDialog(owner); + if (result != JFileChooser.APPROVE_OPTION || chooser.getSelectedFile() == null) { + return ProjectPathSelection.cancelled(); + } + return ProjectPathSelection.selected( + chooser.getSelectedFile().toPath().toAbsolutePath().normalize().toString()); + } finally { + owner.dispose(); + } + } + + private JFrame foregroundOwner() { + JFrame owner = new JFrame("Thingifier Project Browser"); + owner.setType(Window.Type.UTILITY); + owner.setUndecorated(true); + owner.setAlwaysOnTop(true); + owner.setSize(1, 1); + owner.setLocationRelativeTo(null); + owner.setVisible(true); + owner.toFront(); + owner.requestFocus(); + return owner; + } + + private String rootMessage(final Throwable throwable) { + Throwable cause = throwable; + while (cause.getCause() != null) { + cause = cause.getCause(); + } + return cause.getMessage() == null ? cause.getClass().getSimpleName() : cause.getMessage(); + } +} diff --git a/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/WorkspaceDataImporter.java b/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/WorkspaceDataImporter.java index fdbca35c..e70c00ac 100644 --- a/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/WorkspaceDataImporter.java +++ b/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/WorkspaceDataImporter.java @@ -74,7 +74,8 @@ private void importInstance( final Map importedIdentifiers) { Map body = new LinkedHashMap<>(); for (Map.Entry field : instance.entrySet()) { - body.put(String.valueOf(field.getKey()), field.getValue()); + String fieldName = String.valueOf(field.getKey()); + body.put(fieldName, normalizedValue(entity.fieldNamed(fieldName), field.getValue())); } String oldId = stringValue(body.get(entity.primaryKeyFieldName())); if (hasAutoPrimaryKey(entity)) { @@ -155,6 +156,33 @@ private boolean hasAutoPrimaryKey(final EntityDefinitionSpec entity) { || "auto-guid".equals(primaryKey.type())); } + private Object normalizedValue(final FieldDefinitionSpec field, final Object value) { + if (field == null || value == null) { + return value; + } + if ("boolean".equals(field.type()) && value instanceof String) { + String text = ((String) value).trim(); + if ("true".equalsIgnoreCase(text) || "false".equalsIgnoreCase(text)) { + return Boolean.valueOf(text); + } + } + if ("integer".equals(field.type()) && value instanceof String) { + try { + return Integer.valueOf(((String) value).trim()); + } catch (NumberFormatException e) { + return value; + } + } + if ("float".equals(field.type()) && value instanceof String) { + try { + return Double.valueOf(((String) value).trim()); + } catch (NumberFormatException e) { + return value; + } + } + return value; + } + private Map mapValue(final Object value, final String errorMessage) { if (value instanceof Map) { return (Map) value; diff --git a/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/WorkspaceMetadataJson.java b/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/WorkspaceMetadataJson.java index 57609434..75427e0b 100644 --- a/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/WorkspaceMetadataJson.java +++ b/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/WorkspaceMetadataJson.java @@ -25,6 +25,7 @@ public Map toMap(final WorkspaceSnapshot snapshot) { body.put("relationships", relationshipMaps(snapshot)); body.put("schemaYaml", snapshot.schemaYaml()); body.put("project", projectMap(snapshot)); + body.put("storage", storageMap(snapshot)); return body; } @@ -50,6 +51,14 @@ private Map projectMap(final WorkspaceSnapshot snapshot) { return project; } + private Map storageMap(final WorkspaceSnapshot snapshot) { + Map storage = new LinkedHashMap<>(); + storage.put("mode", snapshot.storage().mode()); + storage.put("sqliteFile", snapshot.storage().sqliteFilePath()); + storage.put("fileBacked", snapshot.storage().isSqliteFile()); + return storage; + } + private List> entityMaps(final WorkspaceSnapshot snapshot) { List> entities = new ArrayList<>(); for (EntityDefinitionSpec entity : snapshot.definition().entities()) { diff --git a/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/WorkspaceProjectService.java b/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/WorkspaceProjectService.java index d8208244..ce95035a 100644 --- a/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/WorkspaceProjectService.java +++ b/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/WorkspaceProjectService.java @@ -5,8 +5,14 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import uk.co.compendiumdev.thingifier.Thingifier; +import uk.co.compendiumdev.thingifier.application.schema.definition.ThingifierModelAssembler; import uk.co.compendiumdev.thingifier.yaml.ThingifierProjectManifest; import uk.co.compendiumdev.thingifier.yaml.ThingifierProjectManifestYaml; import uk.co.compendiumdev.thingifier.yaml.ThingifierYamlException; @@ -14,6 +20,11 @@ public final class WorkspaceProjectService { + private static final String FILE_TYPE_BASE64 = "base64"; + private static final String FILE_TYPE_TEXT = "text"; + private static final String PROJECT_STORAGE_JSON = "json"; + private static final String PROJECT_STORAGE_SQLITE = "sqlite"; + private final ActiveThingifierWorkspace workspace; private final WorkspaceMetadataJson metadataJson; private final ThingifierProjectManifestYaml manifestYaml; @@ -30,29 +41,17 @@ public WorkspaceProjectService( public UiHttpResponse save(final String requestJson) { Path projectFolder = projectFolderFromRequest(requestJson); WorkspaceSnapshot snapshot = workspace.snapshot(); - ThingifierProjectManifest manifest = - ThingifierProjectManifest.defaultFor( - snapshot.definition().title(), snapshot.definition().description()); - WorkspaceDataExporter exporter = - new WorkspaceDataExporter(workspace, new DynamicThingifierApiProxy(workspace)); + String projectStorageMode = projectStorageModeFromRequest(requestJson, snapshot); try { if (Files.exists(projectFolder) && !Files.isDirectory(projectFolder)) { throw new CrudUiException(400, "Project path must be a folder"); } Files.createDirectories(projectFolder); - writeString( - projectFolder.resolve(ThingifierProjectManifest.DEFAULT_SCHEMA_FILE), - snapshot.schemaYaml()); - writeString( - projectFolder.resolve(ThingifierProjectManifest.DEFAULT_DATA_FILE), - exporter.exportProjectDataJson()); - writeString( - projectFolder.resolve(ThingifierProjectManifest.DEFAULT_MANIFEST_FILE), - manifestYaml.export(manifest)); WorkspaceSnapshot saved = - workspace.markProjectSaved( - projectFolder, manifest.title(), manifest.description()); + PROJECT_STORAGE_SQLITE.equals(projectStorageMode) + ? saveSqliteBackedProject(projectFolder, snapshot) + : saveJsonBackedProject(projectFolder, snapshot); return UiHttpResponse.json(200, metadataJson.toJson(saved, "saved")); } catch (IOException e) { throw new CrudUiException(400, "Could not save project: " + e.getMessage()); @@ -62,19 +61,91 @@ public UiHttpResponse save(final String requestJson) { public UiHttpResponse load(final String requestJson) { Path requestedPath = projectPathFromRequest(requestJson); ProjectBundle bundle = ProjectBundle.resolve(requestedPath); + return loadProjectBundle(bundle, bundle.folder().toString()); + } + + public UiHttpResponse check(final String requestJson) { + ProjectActionRequest request = ProjectActionRequest.fromJson(requestJson, true); + if (request.isSave()) { + return UiHttpResponse.json(200, JsonSupport.toJson(checkSavePath(request.path()))); + } + return UiHttpResponse.json(200, JsonSupport.toJson(checkLoadPath(request.path()))); + } + + public UiHttpResponse exportFiles() { + return exportFiles("{}"); + } + + public UiHttpResponse exportFiles(final String requestJson) { + try { + WorkspaceSnapshot snapshot = workspace.snapshot(); + String projectStorageMode = projectStorageModeFromRequest(requestJson, snapshot); + List> files = + PROJECT_STORAGE_SQLITE.equals(projectStorageMode) + ? sqliteBackedProjectFiles(snapshot) + : jsonBackedProjectFiles(snapshot); + Map body = new LinkedHashMap<>(); + body.put("formatVersion", 1); + body.put("projectStatus", "exported"); + body.put("files", files); + body.put("projectStorageMode", projectStorageMode); + body.put( + "storageMode", + PROJECT_STORAGE_SQLITE.equals(projectStorageMode) + ? WorkspaceStorage.MODE_SQLITE_FILE + : WorkspaceStorage.MODE_MEMORY); + return UiHttpResponse.json(200, JsonSupport.toJson(body)); + } catch (IOException e) { + throw new CrudUiException(400, "Could not export project files: " + e.getMessage()); + } + } + + public UiHttpResponse loadFiles(final String requestJson) { + Map request = + JsonSupport.fromJsonMap( + requestJson, + "Project file load request must contain a JSON object", + "Could not parse project file load JSON"); + String folderName = stringValue(request.get("folderName")).trim(); + Object filesValue = request.get("files"); + if (!(filesValue instanceof List)) { + throw new CrudUiException(400, "Project file load request must contain files"); + } + + try { + Path tempFolder = Files.createTempDirectory("thingifier-crud-ui-browser-project-"); + writePayloadFiles(tempFolder, (List) filesValue); + ProjectBundle bundle = ProjectBundle.resolve(tempFolder); + return loadProjectBundle(bundle, browserProjectDisplay(folderName)); + } catch (IOException e) { + throw new CrudUiException(400, "Could not load project files: " + e.getMessage()); + } + } + + private UiHttpResponse loadProjectBundle( + final ProjectBundle bundle, final String projectPathDisplay) { ThingifierProjectManifest manifest; String schemaYaml; - String dataJson; try { manifest = manifestYaml.load(bundle.manifestPath()); schemaYaml = Files.readString(bundle.resolve(manifest.schemaFile())); - dataJson = Files.readString(bundle.resolve(manifest.dataFile())); } catch (IOException e) { throw new CrudUiException(400, "Could not load project: " + e.getMessage()); } catch (ThingifierYamlException e) { throw new CrudUiException(400, e.getMessage()); } + if (isSqliteBackedProject(bundle, manifest)) { + return loadSqliteBackedProject(bundle, manifest, schemaYaml, projectPathDisplay); + } + + String dataJson; + try { + dataJson = Files.readString(bundle.resolve(manifest.dataFile())); + } catch (IOException e) { + throw new CrudUiException(400, "Could not load project: " + e.getMessage()); + } + ActiveThingifierWorkspace staging = null; try { Thingifier stagedThingifier = yamlLoader.loadThingifier(schemaYaml); @@ -88,7 +159,11 @@ public UiHttpResponse load(final String requestJson) { Thingifier released = staging.releaseThingifier(); WorkspaceSnapshot loaded = workspace.replaceWithProjectThingifier( - released, bundle.folder(), manifest.title(), manifest.description()); + released, + WorkspaceStorage.memory(), + projectPathDisplay, + manifest.title(), + manifest.description()); return UiHttpResponse.json(200, metadataJson.toJson(loaded, "loaded")); } finally { if (staging != null) { @@ -97,6 +172,364 @@ public UiHttpResponse load(final String requestJson) { } } + private WorkspaceSnapshot saveJsonBackedProject( + final Path projectFolder, final WorkspaceSnapshot snapshot) throws IOException { + ThingifierProjectManifest manifest = + ThingifierProjectManifest.defaultFor( + snapshot.definition().title(), snapshot.definition().description()); + WorkspaceDataExporter exporter = + new WorkspaceDataExporter(workspace, new DynamicThingifierApiProxy(workspace)); + + writeString( + projectFolder.resolve(ThingifierProjectManifest.DEFAULT_SCHEMA_FILE), + snapshot.schemaYaml()); + writeString( + projectFolder.resolve(ThingifierProjectManifest.DEFAULT_DATA_FILE), + exporter.exportProjectDataJson()); + writeString( + projectFolder.resolve(ThingifierProjectManifest.DEFAULT_MANIFEST_FILE), + manifestYaml.export(manifest)); + if (!WorkspaceStorage.MODE_MEMORY.equals(workspace.snapshot().storage().mode())) { + workspace.switchStorage(WorkspaceStorage.memory()); + } + return workspace.markProjectSaved(projectFolder, manifest.title(), manifest.description()); + } + + private WorkspaceSnapshot saveSqliteBackedProject( + final Path projectFolder, final WorkspaceSnapshot snapshot) throws IOException { + ThingifierProjectManifest manifest = + ThingifierProjectManifest.sqliteFileBackedFor( + snapshot.definition().title(), snapshot.definition().description()); + Path targetDatabase = projectFolder.resolve(manifest.sqliteFile()).normalize(); + if (!targetDatabase.startsWith(projectFolder)) { + throw new CrudUiException(400, "Project SQLite file path escapes the project folder"); + } + + writeString( + projectFolder.resolve(ThingifierProjectManifest.DEFAULT_SCHEMA_FILE), + snapshot.schemaYaml()); + if (!samePath(targetDatabase, snapshot.storage().sqliteFile())) { + migrateWorkspaceToSqliteFile(targetDatabase); + } + writeString( + projectFolder.resolve(ThingifierProjectManifest.DEFAULT_MANIFEST_FILE), + manifestYaml.export(manifest)); + return workspace.markProjectSaved(projectFolder, manifest.title(), manifest.description()); + } + + private void migrateWorkspaceToSqliteFile(final Path targetDatabase) throws IOException { + String dataJson = + new WorkspaceDataExporter(workspace, new DynamicThingifierApiProxy(workspace)) + .exportProjectDataJson(); + deleteDatabaseFiles(targetDatabase); + + ActiveThingifierWorkspace staging = null; + try { + WorkspaceSnapshot snapshot = workspace.snapshot(); + WorkspaceStorage targetStorage = WorkspaceStorage.sqliteFile(targetDatabase); + Thingifier stagedThingifier = + new ThingifierModelAssembler() + .assemble(snapshot.definition(), targetStorage.provider()); + stagedThingifier.clearAllData(); + staging = ActiveThingifierWorkspace.forThingifier(stagedThingifier, targetStorage); + new WorkspaceDataImporter( + staging, + new DynamicThingifierApiProxy(staging), + new WorkspaceMetadataJson()) + .importDataIntoCurrentWorkspace(dataJson); + Thingifier released = staging.releaseThingifier(); + workspace.replaceWithProjectThingifier( + released, + targetStorage, + targetDatabase.getParent(), + snapshot.definition().title(), + snapshot.definition().description()); + } finally { + if (staging != null) { + staging.close(); + } + } + } + + private UiHttpResponse loadSqliteBackedProject( + final ProjectBundle bundle, + final ThingifierProjectManifest manifest, + final String schemaYaml, + final String projectPathDisplay) { + WorkspaceStorage storage = WorkspaceStorage.sqliteFile(sqliteDataPath(bundle, manifest)); + Thingifier stagedThingifier = yamlLoader.loadThingifier(schemaYaml, storage.provider()); + WorkspaceSnapshot loaded = + workspace.replaceWithProjectThingifier( + stagedThingifier, + storage, + projectPathDisplay, + manifest.title(), + manifest.description()); + return UiHttpResponse.json(200, metadataJson.toJson(loaded, "loaded")); + } + + private boolean isSqliteBackedProject( + final ProjectBundle bundle, final ThingifierProjectManifest manifest) { + if (manifest.isSqliteFileStorage()) { + return true; + } + if (manifest.dataFile().isEmpty()) { + return false; + } + return hasSqliteHeader(bundle.resolve(manifest.dataFile())); + } + + private Path sqliteDataPath( + final ProjectBundle bundle, final ThingifierProjectManifest manifest) { + String sqliteFile = + manifest.sqliteFile().isEmpty() ? manifest.dataFile() : manifest.sqliteFile(); + return bundle.resolve(sqliteFile); + } + + private boolean hasSqliteHeader(final Path path) { + if (!Files.isRegularFile(path)) { + return false; + } + byte[] expected = "SQLite format 3\u0000".getBytes(StandardCharsets.US_ASCII); + byte[] actual = new byte[expected.length]; + try (java.io.InputStream input = Files.newInputStream(path)) { + int read = input.read(actual); + return read == expected.length && Arrays.equals(expected, actual); + } catch (IOException e) { + return false; + } + } + + private Map checkSavePath(final String pathText) { + Path path = Paths.get(pathText).toAbsolutePath().normalize(); + Map body = baseCheckBody(ProjectActionRequest.ACTION_SAVE, path); + if (path.getFileName() != null + && ThingifierProjectManifest.DEFAULT_MANIFEST_FILE.equals( + path.getFileName().toString())) { + body.put("message", "Project save path must be a folder"); + return body; + } + if (Files.exists(path) && !Files.isDirectory(path)) { + body.put("message", "Project path must be a folder"); + return body; + } + List managedFiles = managedFilesIn(path); + body.put("valid", true); + body.put("canProceed", true); + body.put("kind", Files.exists(path) ? "folder" : "creatable-folder"); + body.put("exists", Files.exists(path)); + body.put("managedFiles", managedFiles); + body.put( + "message", + Files.exists(path) + ? "Project folder is available." + : "Project folder can be created."); + if (!managedFiles.isEmpty()) { + body.put( + "warning", + "Saving will overwrite managed project files: " + + String.join(", ", managedFiles)); + } + return body; + } + + private Map checkLoadPath(final String pathText) { + Path path = Paths.get(pathText).toAbsolutePath().normalize(); + Map body = baseCheckBody(ProjectActionRequest.ACTION_LOAD, path); + ProjectBundle bundle = ProjectBundle.resolve(path); + Path manifestPath = bundle.manifestPath(); + body.put("kind", Files.isDirectory(path) ? "folder" : "project-file"); + if (!Files.exists(manifestPath)) { + body.put("message", "Project file does not exist."); + return body; + } + if (!Files.isRegularFile(manifestPath)) { + body.put("message", "Project file path must be a file."); + return body; + } + try { + ThingifierProjectManifest manifest = manifestYaml.load(manifestPath); + Path schemaFile = bundle.resolve(manifest.schemaFile()); + if (!Files.exists(schemaFile)) { + body.put("message", "Project schema file does not exist."); + return body; + } + if (isSqliteBackedProject(bundle, manifest)) { + Path sqliteFile = sqliteDataPath(bundle, manifest); + if (!Files.exists(sqliteFile)) { + body.put("message", "Project SQLite file does not exist."); + return body; + } + } else { + Path dataFile = bundle.resolve(manifest.dataFile()); + if (!Files.exists(dataFile)) { + body.put("message", "Project data file does not exist."); + return body; + } + } + body.put("valid", true); + body.put("canProceed", true); + body.put("message", "Project can be loaded."); + } catch (ThingifierYamlException e) { + body.put("message", e.getMessage()); + } catch (IOException e) { + body.put("message", "Could not check project: " + e.getMessage()); + } + return body; + } + + private Map baseCheckBody(final String action, final Path path) { + Map body = new LinkedHashMap<>(); + body.put("action", action); + body.put("path", path.toString()); + body.put("valid", false); + body.put("canProceed", false); + body.put("managedFiles", List.of()); + body.put("warning", ""); + return body; + } + + private List managedFilesIn(final Path folder) { + List managedFiles = new ArrayList<>(); + if (!Files.isDirectory(folder)) { + return managedFiles; + } + addIfExists(managedFiles, folder, ThingifierProjectManifest.DEFAULT_MANIFEST_FILE); + addIfExists(managedFiles, folder, ThingifierProjectManifest.DEFAULT_SCHEMA_FILE); + addIfExists(managedFiles, folder, ThingifierProjectManifest.DEFAULT_DATA_FILE); + addIfExists(managedFiles, folder, ThingifierProjectManifest.DEFAULT_SQLITE_FILE); + return managedFiles; + } + + private void addIfExists(final List files, final Path folder, final String name) { + if (Files.exists(folder.resolve(name))) { + files.add(name); + } + } + + private List> jsonBackedProjectFiles(final WorkspaceSnapshot snapshot) { + ThingifierProjectManifest manifest = + ThingifierProjectManifest.defaultFor( + snapshot.definition().title(), snapshot.definition().description()); + WorkspaceDataExporter exporter = + new WorkspaceDataExporter(workspace, new DynamicThingifierApiProxy(workspace)); + List> files = new ArrayList<>(); + files.add( + textFile( + ThingifierProjectManifest.DEFAULT_MANIFEST_FILE, + manifestYaml.export(manifest))); + files.add(textFile(ThingifierProjectManifest.DEFAULT_SCHEMA_FILE, snapshot.schemaYaml())); + files.add( + textFile( + ThingifierProjectManifest.DEFAULT_DATA_FILE, + exporter.exportProjectDataJson())); + return files; + } + + private List> sqliteBackedProjectFiles(final WorkspaceSnapshot snapshot) + throws IOException { + ThingifierProjectManifest manifest = + ThingifierProjectManifest.sqliteFileBackedFor( + snapshot.definition().title(), snapshot.definition().description()); + List> files = new ArrayList<>(); + files.add( + textFile( + ThingifierProjectManifest.DEFAULT_MANIFEST_FILE, + manifestYaml.export(manifest))); + files.add(textFile(ThingifierProjectManifest.DEFAULT_SCHEMA_FILE, snapshot.schemaYaml())); + files.add( + binaryFile( + ThingifierProjectManifest.DEFAULT_SQLITE_FILE, + sqliteDatabaseBytesForSnapshot(snapshot))); + return files; + } + + private byte[] sqliteDatabaseBytesForSnapshot(final WorkspaceSnapshot snapshot) + throws IOException { + String dataJson = + new WorkspaceDataExporter(workspace, new DynamicThingifierApiProxy(workspace)) + .exportProjectDataJson(); + Path tempFolder = Files.createTempDirectory("thingifier-crud-ui-project-export-"); + Path tempDatabase = tempFolder.resolve(ThingifierProjectManifest.DEFAULT_SQLITE_FILE); + ActiveThingifierWorkspace staging = null; + try { + WorkspaceStorage targetStorage = WorkspaceStorage.sqliteFile(tempDatabase); + Thingifier stagedThingifier = + new ThingifierModelAssembler() + .assemble(snapshot.definition(), targetStorage.provider()); + stagedThingifier.clearAllData(); + staging = ActiveThingifierWorkspace.forThingifier(stagedThingifier, targetStorage); + new WorkspaceDataImporter( + staging, + new DynamicThingifierApiProxy(staging), + new WorkspaceMetadataJson()) + .importDataIntoCurrentWorkspace(dataJson); + } finally { + if (staging != null) { + staging.close(); + } + } + byte[] bytes = Files.readAllBytes(tempDatabase); + deleteDatabaseFiles(tempDatabase); + Files.deleteIfExists(tempFolder); + return bytes; + } + + private Map textFile(final String name, final String contents) { + Map file = new LinkedHashMap<>(); + file.put("name", name); + file.put("type", FILE_TYPE_TEXT); + file.put("content", contents); + return file; + } + + private Map binaryFile(final String name, final byte[] contents) { + Map file = new LinkedHashMap<>(); + file.put("name", name); + file.put("type", FILE_TYPE_BASE64); + file.put("content", Base64.getEncoder().encodeToString(contents)); + return file; + } + + private void writePayloadFiles(final Path folder, final List files) throws IOException { + for (Object value : files) { + if (!(value instanceof Map)) { + throw new CrudUiException(400, "Project files must be objects"); + } + Map file = (Map) value; + String name = stringValue(file.get("name")).trim(); + String type = stringValue(file.get("type")).trim(); + String content = stringValue(file.get("content")); + Path target = payloadPath(folder, name); + if (FILE_TYPE_BASE64.equals(type)) { + Files.write(target, Base64.getDecoder().decode(content)); + } else if (FILE_TYPE_TEXT.equals(type) || type.isEmpty()) { + writeString(target, content); + } else { + throw new CrudUiException(400, "Unsupported project file type: " + type); + } + } + } + + private Path payloadPath(final Path folder, final String fileName) { + if (fileName.isEmpty() + || fileName.contains("/") + || fileName.contains("\\") + || Paths.get(fileName).isAbsolute()) { + throw new CrudUiException(400, "Project file names must be project-root files"); + } + Path resolved = folder.resolve(fileName).normalize(); + if (!resolved.startsWith(folder)) { + throw new CrudUiException(400, "Project file path escapes the project folder"); + } + return resolved; + } + + private String browserProjectDisplay(final String folderName) { + String safeName = folderName.isEmpty() ? "selected folder" : folderName; + return "Browser folder: " + safeName; + } + private Path projectFolderFromRequest(final String requestJson) { Path path = projectPathFromRequest(requestJson).toAbsolutePath().normalize(); if (path.getFileName() != null @@ -108,11 +541,7 @@ private Path projectFolderFromRequest(final String requestJson) { } private Path projectPathFromRequest(final String requestJson) { - Map request = - JsonSupport.fromJsonMap( - requestJson, - "Project request must contain a JSON object", - "Could not parse project request JSON"); + Map request = projectRequestMap(requestJson); String path = stringValue(request.get("path")).trim(); if (path.isEmpty()) { throw new CrudUiException(400, "Project request must contain path"); @@ -120,10 +549,63 @@ private Path projectPathFromRequest(final String requestJson) { return Paths.get(path); } + private String projectStorageModeFromRequest( + final String requestJson, final WorkspaceSnapshot snapshot) { + Map request = optionalProjectRequestMap(requestJson); + String requested = stringValue(request.get("projectStorageMode")).trim(); + if (requested.isEmpty()) { + requested = stringValue(request.get("storageMode")).trim(); + } + if (requested.isEmpty()) { + return snapshot.storage().isSqliteFile() + ? PROJECT_STORAGE_SQLITE + : PROJECT_STORAGE_JSON; + } + String normalized = requested.toLowerCase().replace("_", "-"); + if (PROJECT_STORAGE_JSON.equals(normalized) + || "data-json".equals(normalized) + || WorkspaceStorage.MODE_MEMORY.equals(normalized)) { + return PROJECT_STORAGE_JSON; + } + if (PROJECT_STORAGE_SQLITE.equals(normalized) + || WorkspaceStorage.MODE_SQLITE_FILE.equals(normalized) + || "data-sqlite".equals(normalized)) { + return PROJECT_STORAGE_SQLITE; + } + throw new CrudUiException(400, "Project storage mode must be json or sqlite"); + } + + private Map optionalProjectRequestMap(final String requestJson) { + if (requestJson == null || requestJson.trim().isEmpty()) { + return Map.of(); + } + return projectRequestMap(requestJson); + } + + private Map projectRequestMap(final String requestJson) { + return JsonSupport.fromJsonMap( + requestJson, + "Project request must contain a JSON object", + "Could not parse project request JSON"); + } + private void writeString(final Path path, final String contents) throws IOException { Files.writeString(path, contents, StandardCharsets.UTF_8); } + private void deleteDatabaseFiles(final Path databasePath) throws IOException { + Files.deleteIfExists(databasePath); + Files.deleteIfExists(Path.of(databasePath.toString() + "-wal")); + Files.deleteIfExists(Path.of(databasePath.toString() + "-shm")); + } + + private boolean samePath(final Path first, final Path second) { + if (first == null || second == null) { + return false; + } + return first.toAbsolutePath().normalize().equals(second.toAbsolutePath().normalize()); + } + private String stringValue(final Object value) { return value == null ? "" : String.valueOf(value); } diff --git a/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/WorkspaceSchemaUpgradeService.java b/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/WorkspaceSchemaUpgradeService.java index 23535db2..90b30432 100644 --- a/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/WorkspaceSchemaUpgradeService.java +++ b/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/WorkspaceSchemaUpgradeService.java @@ -64,7 +64,9 @@ public UiHttpResponse apply(final String jsonText) { try { WorkspaceSnapshot upgraded = workspace.replaceWithMigratedThingifier( - result.targetThingifier(), request.expectedWorkspaceVersion()); + result.targetThingifier(), + result.targetStorage(), + request.expectedWorkspaceVersion()); result.releaseTargetThingifier(); result.markApplied(upgraded, metadataJson.toMap(upgraded)); return UiHttpResponse.json(200, JsonSupport.toJson(result.body())); @@ -104,11 +106,14 @@ private MigrationResult migrationResultFor(final UpgradeRequest request) { result.addWarnings(mappings.warnings()); result.putSummary(mappings.summaryMap(sourceData)); - Thingifier targetThingifier = assembler.assemble(request.definition()); + WorkspaceStorage targetStorage = targetStorageFor(snapshot.storage()); + Thingifier targetThingifier = + assembler.assemble(request.definition(), targetStorage.provider()); + targetThingifier.clearAllData(); try { migrate(sourceData, request.definition(), targetThingifier, mappings, result); validateFinalData(request.definition(), targetThingifier, result); - result.targetThingifier(targetThingifier); + result.targetThingifier(targetThingifier, targetStorage); } catch (IllegalArgumentException | IllegalStateException | IndexOutOfBoundsException e) { targetThingifier.close(); result.addBlockingError("migration", e.getMessage()); @@ -117,6 +122,13 @@ private MigrationResult migrationResultFor(final UpgradeRequest request) { return result; } + private WorkspaceStorage targetStorageFor(final WorkspaceStorage currentStorage) { + if (WorkspaceStorage.MODE_SQLITE_MEMORY.equals(currentStorage.mode())) { + return WorkspaceStorage.sqliteMemory(); + } + return WorkspaceStorage.memory(); + } + private void migrate( final SourceWorkspaceData sourceData, final ThingifierModelDefinition targetDefinition, @@ -1129,6 +1141,7 @@ private static final class MigrationResult { private final List> coercions; private final List> valueAssignments; private Thingifier targetThingifier; + private WorkspaceStorage targetStorage; private int statusCode; private MigrationResult(final long workspaceVersion) { @@ -1245,14 +1258,20 @@ private void addBlockingError(final String path, final String message) { errors.add(error); } - private void targetThingifier(final Thingifier targetThingifier) { + private void targetThingifier( + final Thingifier targetThingifier, final WorkspaceStorage targetStorage) { this.targetThingifier = targetThingifier; + this.targetStorage = targetStorage; } private Thingifier targetThingifier() { return targetThingifier; } + private WorkspaceStorage targetStorage() { + return targetStorage == null ? WorkspaceStorage.memory() : targetStorage; + } + private boolean canApply() { return Boolean.TRUE.equals(body.get("canApply")); } @@ -1293,12 +1312,14 @@ private Map body() { private void releaseTargetThingifier() { targetThingifier = null; + targetStorage = null; } private void closeTargetThingifier() { if (targetThingifier != null) { targetThingifier.close(); targetThingifier = null; + targetStorage = null; } } } diff --git a/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/WorkspaceSnapshot.java b/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/WorkspaceSnapshot.java index ec6a2a73..c93035e7 100644 --- a/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/WorkspaceSnapshot.java +++ b/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/WorkspaceSnapshot.java @@ -9,6 +9,7 @@ public final class WorkspaceSnapshot { private final Thingifier thingifier; private final ThingifierModelDefinition definition; private final String schemaYaml; + private final WorkspaceStorage storage; private final String projectPath; private final String projectTitle; private final String projectDescription; @@ -18,7 +19,7 @@ public WorkspaceSnapshot( final Thingifier thingifier, final ThingifierModelDefinition definition, final String schemaYaml) { - this(version, thingifier, definition, schemaYaml, "", "", ""); + this(version, thingifier, definition, schemaYaml, WorkspaceStorage.memory(), "", "", ""); } public WorkspaceSnapshot( @@ -26,6 +27,7 @@ public WorkspaceSnapshot( final Thingifier thingifier, final ThingifierModelDefinition definition, final String schemaYaml, + final WorkspaceStorage storage, final String projectPath, final String projectTitle, final String projectDescription) { @@ -33,6 +35,7 @@ public WorkspaceSnapshot( this.thingifier = thingifier; this.definition = definition; this.schemaYaml = schemaYaml; + this.storage = storage == null ? WorkspaceStorage.memory() : storage; this.projectPath = nullToEmpty(projectPath); this.projectTitle = nullToEmpty(projectTitle); this.projectDescription = nullToEmpty(projectDescription); @@ -54,6 +57,10 @@ public String schemaYaml() { return schemaYaml; } + public WorkspaceStorage storage() { + return storage; + } + public String projectPath() { return projectPath; } diff --git a/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/WorkspaceStorage.java b/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/WorkspaceStorage.java new file mode 100644 index 00000000..d2c32511 --- /dev/null +++ b/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/WorkspaceStorage.java @@ -0,0 +1,105 @@ +package uk.co.compendiumdev.thingifier.crudui; + +import java.nio.file.Path; +import uk.co.compendiumdev.thingifier.core.repository.ThingStoreProvider; +import uk.co.compendiumdev.thingifier.core.repository.ThingStoreProviderConfig; +import uk.co.compendiumdev.thingifier.core.repository.inmemory.InMemoryThingStoreProvider; +import uk.co.compendiumdev.thingifier.core.repository.sqlite.SqliteThingStoreProvider; + +public final class WorkspaceStorage { + + public static final String MODE_MEMORY = "memory"; + public static final String MODE_SQLITE_MEMORY = "sqlite-memory"; + public static final String MODE_SQLITE_FILE = "sqlite-file"; + + private final String mode; + private final Path sqliteFile; + + private WorkspaceStorage(final String mode, final Path sqliteFile) { + this.mode = mode; + this.sqliteFile = sqliteFile == null ? null : sqliteFile.toAbsolutePath().normalize(); + } + + public static WorkspaceStorage memory() { + return new WorkspaceStorage(MODE_MEMORY, null); + } + + public static WorkspaceStorage sqliteMemory() { + return new WorkspaceStorage(MODE_SQLITE_MEMORY, null); + } + + public static WorkspaceStorage sqliteFile(final Path sqliteFile) { + if (sqliteFile == null || sqliteFile.toString().trim().isEmpty()) { + throw new IllegalArgumentException("SQLite file path is required"); + } + return new WorkspaceStorage(MODE_SQLITE_FILE, sqliteFile); + } + + public static WorkspaceStorage fromConfig(final ThingStoreProviderConfig config) { + String mode = normalize(config.getRepositoryMode()); + if (MODE_SQLITE_MEMORY.equals(mode) + || "sqlite".equals(mode) + || "sqlite-in-memory".equals(mode)) { + return sqliteMemory(); + } + if (MODE_SQLITE_FILE.equals(mode) || "sqlite-disk".equals(mode) || "file".equals(mode)) { + if (config.hasSqliteFile()) { + return sqliteFile(config.getSqliteFile()); + } + return sqliteFile(config.getSqliteDirectory().resolve("thingifier.sqlite")); + } + return memory(); + } + + public static WorkspaceStorage fromModeAndPath(final String mode, final String sqliteFile) { + String normalizedMode = normalize(mode); + if (normalizedMode.isEmpty() || MODE_MEMORY.equals(normalizedMode)) { + return memory(); + } + if (MODE_SQLITE_MEMORY.equals(normalizedMode) + || "sqlite".equals(normalizedMode) + || "sqlite-in-memory".equals(normalizedMode)) { + return sqliteMemory(); + } + if (MODE_SQLITE_FILE.equals(normalizedMode) + || "sqlite-disk".equals(normalizedMode) + || "file".equals(normalizedMode)) { + return sqliteFile(Path.of(nullToEmpty(sqliteFile).trim())); + } + throw new IllegalArgumentException("Unknown workspace storage mode " + mode); + } + + public ThingStoreProvider provider() { + if (MODE_SQLITE_MEMORY.equals(mode)) { + return SqliteThingStoreProvider.inMemory(); + } + if (MODE_SQLITE_FILE.equals(mode)) { + return SqliteThingStoreProvider.fileBackedFile(sqliteFile); + } + return new InMemoryThingStoreProvider(); + } + + public String mode() { + return mode; + } + + public boolean isSqliteFile() { + return MODE_SQLITE_FILE.equals(mode); + } + + public String sqliteFilePath() { + return sqliteFile == null ? "" : sqliteFile.toString(); + } + + public Path sqliteFile() { + return sqliteFile; + } + + private static String normalize(final String value) { + return nullToEmpty(value).trim().toLowerCase().replace("_", "-"); + } + + private static String nullToEmpty(final String value) { + return value == null ? "" : value; + } +} diff --git a/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/adapter/spark/CrudUiApplication.java b/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/adapter/spark/CrudUiApplication.java index 2526298b..03fd731f 100644 --- a/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/adapter/spark/CrudUiApplication.java +++ b/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/adapter/spark/CrudUiApplication.java @@ -66,6 +66,23 @@ public void configureRoutes() { Spark.post( "/ui/project/load", (request, response) -> write(response, controller.loadProject(request.body()))); + Spark.post( + "/ui/project/browse", + (request, response) -> write(response, controller.browseProject(request.body()))); + Spark.post( + "/ui/project/check", + (request, response) -> write(response, controller.checkProject(request.body()))); + Spark.post( + "/ui/project/export-files", + (request, response) -> + write(response, controller.exportProjectFiles(request.body()))); + Spark.post( + "/ui/project/load-files", + (request, response) -> + write(response, controller.loadProjectFiles(request.body()))); + Spark.post( + "/ui/storage/switch", + (request, response) -> write(response, controller.switchStorage(request.body()))); Spark.get( "/docs", (request, response) -> write(response, controller.apiDocumentationPage())); Spark.get( @@ -116,6 +133,8 @@ private String write(final Response response, final UiHttpResponse uiResponse) { @Override public void close() { + Spark.stop(); + Spark.awaitStop(); workspace.close(); } } diff --git a/thingifier-crud-ui/src/main/resources/public/assets/app.js b/thingifier-crud-ui/src/main/resources/public/assets/app.js index e4f67db7..da2f5934 100644 --- a/thingifier-crud-ui/src/main/resources/public/assets/app.js +++ b/thingifier-crud-ui/src/main/resources/public/assets/app.js @@ -48,6 +48,9 @@ const els = { exportButton: document.getElementById("export-button"), saveProjectButton: document.getElementById("save-project-button"), loadProjectButton: document.getElementById("load-project-button"), + storageModeSelect: document.getElementById("storage-mode-select"), + storageFileInput: document.getElementById("storage-file-input"), + switchStorageButton: document.getElementById("switch-storage-button"), yamlFile: document.getElementById("yaml-file"), importFile: document.getElementById("import-file"), projectDialog: document.getElementById("project-dialog"), @@ -55,6 +58,15 @@ const els = { projectDialogDescription: document.getElementById("project-dialog-description"), projectDialogWarning: document.getElementById("project-dialog-warning"), projectPathInput: document.getElementById("project-path-input"), + projectRecentPaths: document.getElementById("project-recent-paths"), + projectBrowseButton: document.getElementById("project-browse-button"), + projectValidateButton: document.getElementById("project-validate-button"), + projectBrowserSaveButton: document.getElementById("project-browser-save-button"), + projectBrowserLoadButton: document.getElementById("project-browser-load-button"), + projectBrowserStatus: document.getElementById("project-browser-status"), + projectSaveStorageOptions: document.getElementById("project-save-storage-options"), + projectSaveStorageJson: document.getElementById("project-save-storage-json"), + projectSaveStorageSqlite: document.getElementById("project-save-storage-sqlite"), projectDialogConfirm: document.getElementById("project-dialog-confirm"), projectDialogCancel: document.getElementById("project-dialog-cancel"), schemaWorkspaceLink: document.getElementById("schema-workspace-link"), @@ -99,6 +111,8 @@ const els = { schemaMermaidDiagram: document.getElementById("schema-mermaid-diagram") }; +const PROJECT_RECENT_PATHS_KEY = "thingifier-crud-ui.projectPaths"; + async function requestJson(path, options = {}) { const response = await fetch(path, { headers: { @@ -133,6 +147,7 @@ async function loadWorkspace() { clearMessage(); state.workspace = await requestJson("/ui/workspace"); renderHeader(); + syncStorageControlsFromWorkspace(); if (!els.tree) { return; } @@ -222,7 +237,18 @@ function renderHeader() { const projectPath = state.workspace.project && state.workspace.project.path ? `Project: ${state.workspace.project.path}` : ""; - els.description.textContent = [description, projectPath].filter(Boolean).join(" | "); + els.description.textContent = [description, projectPath, storageDescription()].filter(Boolean).join(" | "); +} + +function storageDescription() { + const storage = state.workspace && state.workspace.storage ? state.workspace.storage : {}; + if (storage.mode === "sqlite-file") { + return `Storage: SQLite File${storage.sqliteFile ? ` (${storage.sqliteFile})` : ""}`; + } + if (storage.mode === "sqlite-memory") { + return "Storage: SQLite In Memory"; + } + return "Storage: In Memory"; } function renderTree() { @@ -235,6 +261,7 @@ function renderTree() { entity.plural, state.entityCounts[entity.name] ?? "", isExpanded(entityKey)); + setTestId(button, testIdFor("outline-entity", entity.name)); button.addEventListener("click", event => { if (clickedCaret(event)) { toggleExpanded(entityKey); @@ -248,6 +275,7 @@ function renderTree() { if (isExpanded(entityKey)) { const group = document.createElement("div"); group.className = "tree-children entity-children"; + setTestId(group, testIdFor("outline-entity-children", entity.name)); node.instances.forEach(instance => renderInstanceNode(group, entity, instance)); if (node.instances.length === 0) { group.appendChild(emptyTreeNode("No instances")); @@ -265,6 +293,7 @@ function renderInstanceNode(container, entity, instanceNode) { instanceLabel(entity, row), "", isExpanded(instanceKey)); + setTestId(button, testIdFor("outline-instance", entity.name, primaryValue(entity, row))); button.addEventListener("click", event => { if (clickedCaret(event)) { toggleExpanded(instanceKey); @@ -281,6 +310,7 @@ function renderInstanceNode(container, entity, instanceNode) { const relationshipGroup = document.createElement("div"); relationshipGroup.className = "tree-children relationship-group"; + setTestId(relationshipGroup, testIdFor("outline-relationships", entity.name, primaryValue(entity, row))); instanceNode.relationships.forEach(relationshipNode => { renderRelationshipNode(relationshipGroup, entity, row, relationshipNode); }); @@ -298,6 +328,7 @@ function renderRelationshipNode(container, entity, sourceRow, relationshipNode) relationship.name, relationshipNode.rows.length, isExpanded(relationshipKey)); + setTestId(button, testIdFor("outline-relationship", entity.name, primaryValue(entity, sourceRow), relationship.name)); button.addEventListener("click", event => { if (clickedCaret(event)) { toggleExpanded(relationshipKey); @@ -314,12 +345,14 @@ function renderRelationshipNode(container, entity, sourceRow, relationshipNode) const relatedGroup = document.createElement("div"); relatedGroup.className = "tree-children related-instance-group"; + setTestId(relatedGroup, testIdFor("outline-related-instances", entity.name, primaryValue(entity, sourceRow), relationship.name)); relationshipNode.rows.forEach(row => { const relatedButton = treeButton( `relationship-item relationship-instance ${isSelectedRow(relationshipNode.target, row) ? "active" : ""}`, instanceLabel(relationshipNode.target, row), "", null); + setTestId(relatedButton, testIdFor("outline-related-instance", relationshipNode.target.name, primaryValue(relationshipNode.target, row))); relatedButton.addEventListener( "click", () => selectRelatedInstance(relationshipNode.target, relationshipNode.rows, row)); @@ -482,6 +515,7 @@ function renderGrid(rows, gridEntity = state.currentEntity) { const fields = gridEntity.fields; const table = document.createElement("table"); table.className = "data-grid"; + setTestId(table, testIdFor("data-grid", gridEntity.name)); table.appendChild(gridHead(fields, gridEntity, isRelationshipGrid(gridEntity))); table.appendChild(gridBody(filteredRows(rows, gridEntity), fields, gridEntity)); els.gridHost.innerHTML = ""; @@ -508,6 +542,7 @@ function gridHead(fields, entity, hasRelationshipActions = false) { input.className = "filter-input"; input.placeholder = "Filter"; input.value = state.filters[field.name] || ""; + setTestId(input, testIdFor("grid-filter", entity.name, field.name)); input.addEventListener("input", event => { state.filters[field.name] = event.target.value; renderGrid(state.currentRows, entity); @@ -528,6 +563,7 @@ function gridBody(rows, fields, entity) { const tbody = document.createElement("tbody"); rows.forEach(rowData => { const row = document.createElement("tr"); + setTestId(row, testIdFor("grid-row", entity.name, primaryValue(entity, rowData))); if (isSelectedRow(entity, rowData)) { row.className = "selected"; } @@ -544,6 +580,7 @@ function gridBody(rows, fields, entity) { remove.className = "danger compact-button"; remove.textContent = "Remove"; remove.title = "Remove from relationship"; + setTestId(remove, testIdFor("relationship-row-remove", entity.name, primaryValue(entity, rowData))); remove.addEventListener("click", event => { event.stopPropagation(); removeRelationship(rowData); @@ -569,6 +606,7 @@ function relationshipManagementPanel(targetEntity, relatedRows) { const context = state.relationshipContext; const panel = document.createElement("section"); panel.className = "relationship-manager"; + setTestId(panel, "relationship-manager"); const heading = document.createElement("div"); heading.className = "relationship-manager-heading"; @@ -592,6 +630,7 @@ function relationshipManagementPanel(targetEntity, relatedRows) { function connectExistingPanel(targetEntity, relatedRows) { const card = document.createElement("form"); card.className = "relationship-manager-card"; + setTestId(card, "relationship-connect-existing"); const title = document.createElement("h4"); title.textContent = "Connect existing"; card.appendChild(title); @@ -600,6 +639,7 @@ function connectExistingPanel(targetEntity, relatedRows) { const select = document.createElement("select"); select.name = "target"; select.disabled = availableRows.length === 0; + setTestId(select, "relationship-connect-select"); availableRows.forEach(row => { const option = document.createElement("option"); option.value = primaryValue(targetEntity, row); @@ -620,6 +660,7 @@ function connectExistingPanel(targetEntity, relatedRows) { button.className = "primary"; button.textContent = "Connect existing"; button.disabled = availableRows.length === 0; + setTestId(button, "relationship-connect-submit"); card.appendChild(button); card.addEventListener("submit", event => { event.preventDefault(); @@ -631,6 +672,7 @@ function connectExistingPanel(targetEntity, relatedRows) { function createAndConnectPanel(targetEntity) { const card = document.createElement("form"); card.className = "relationship-manager-card relationship-create-form"; + setTestId(card, "relationship-create-and-connect"); const title = document.createElement("h4"); title.textContent = "Create and connect"; card.appendChild(title); @@ -650,6 +692,7 @@ function createAndConnectPanel(targetEntity) { button.type = "submit"; button.className = "primary"; button.textContent = "Create and connect"; + setTestId(button, "relationship-create-submit"); card.appendChild(button); card.addEventListener("submit", event => { event.preventDefault(); @@ -697,6 +740,7 @@ function renderEditor(row = state.selectedRow) { const form = document.createElement("form"); form.className = "editor-form"; + setTestId(form, "editor-form"); entity.fields.forEach(field => form.appendChild(fieldControl(field, row, isCreate))); els.editor.appendChild(form); @@ -706,6 +750,7 @@ function renderEditor(row = state.selectedRow) { save.type = "button"; save.className = "primary"; save.textContent = "Save"; + setTestId(save, "editor-save-button"); save.addEventListener("click", () => saveEntity(entity, form, isCreate)); actions.appendChild(save); if (!isCreate) { @@ -714,6 +759,7 @@ function renderEditor(row = state.selectedRow) { disconnect.type = "button"; disconnect.className = "danger"; disconnect.textContent = "Remove from relationship"; + setTestId(disconnect, "editor-remove-relationship-button"); disconnect.addEventListener("click", () => removeRelationship(row)); actions.appendChild(disconnect); } @@ -721,6 +767,7 @@ function renderEditor(row = state.selectedRow) { remove.type = "button"; remove.className = "danger"; remove.textContent = "Delete"; + setTestId(remove, "editor-delete-button"); remove.addEventListener("click", () => deleteEntity(entity, row)); actions.appendChild(remove); } @@ -730,6 +777,7 @@ function renderEditor(row = state.selectedRow) { function fieldControl(field, row, isCreate, idPrefix = "editor") { const wrap = document.createElement("div"); wrap.className = "field-control"; + setTestId(wrap, testIdFor("field-control", idPrefix, field.name)); const readOnly = field.auto || (!isCreate && field.primary); const autoAssigned = isCreate && field.auto; if (readOnly) { @@ -743,6 +791,7 @@ function fieldControl(field, row, isCreate, idPrefix = "editor") { const input = inputFor(field, value, autoAssigned); input.name = field.name; input.id = `${idPrefix}-${field.name}`; + setTestId(input, testIdFor("field-input", idPrefix, field.name)); label.htmlFor = input.id; wrap.appendChild(label); if (readOnly) { @@ -804,12 +853,27 @@ function inputFor(field, value, forceText = false) { textarea.value = value ? JSON.stringify(value, null, 2) : ""; return textarea; } + if (field.type === "string" && !stringFieldHasMaxLength(field)) { + const textarea = document.createElement("textarea"); + textarea.className = "long-text-input"; + textarea.rows = 4; + textarea.value = value ?? ""; + return textarea; + } const input = document.createElement("input"); input.type = field.type === "date" ? "date" : "text"; input.value = value ?? ""; return input; } +function stringFieldHasMaxLength(field) { + if (field.truncateTo !== null && field.truncateTo !== undefined && field.truncateTo !== "") { + return true; + } + return Array.isArray(field.validations) + && field.validations.some(validation => validation && validation.type === "maximumLength"); +} + async function saveEntity(entity, form, isCreate) { clearMessage(); const body = bodyFromForm(entity, form); @@ -920,7 +984,7 @@ function bodyFromForm(entity, form) { function valueFromInput(field, input) { if (field.type === "boolean") { - return input.checked ? "true" : "false"; + return input.checked; } if (field.type === "object") { try { @@ -1071,6 +1135,74 @@ function clearMessage() { els.message.hidden = true; } +function escapeHtml(value) { + return String(value).replace(/[&<>"']/g, character => ({ + "&": "&", + "<": "<", + ">": ">", + "\"": """, + "'": "'" + }[character])); +} + +function testIdPart(value) { + return String(value ?? "") + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") || "blank"; +} + +function testIdFor(prefix, ...parts) { + return [prefix, ...parts.map(testIdPart)].join("-"); +} + +function setTestId(element, id) { + element.setAttribute("data-testid", id); + return element; +} + +function projectRecentPaths() { + try { + const paths = JSON.parse(localStorage.getItem(PROJECT_RECENT_PATHS_KEY) || "[]"); + return Array.isArray(paths) ? paths.filter(path => typeof path === "string") : []; + } catch (error) { + return []; + } +} + +function rememberProjectPath(path) { + if (!path) { + return; + } + const paths = projectRecentPaths().filter(existing => existing !== path); + paths.unshift(path); + localStorage.setItem(PROJECT_RECENT_PATHS_KEY, JSON.stringify(paths.slice(0, 8))); + renderProjectRecentPaths(); +} + +function renderProjectRecentPaths() { + if (!els.projectRecentPaths) { + return; + } + els.projectRecentPaths.innerHTML = projectRecentPaths() + .map(path => ``) + .join(""); +} + +function setProjectDialogStatus(text, error = false) { + if (!els.projectBrowserStatus) { + return; + } + els.projectBrowserStatus.textContent = text || ""; + els.projectBrowserStatus.className = error + ? "project-browser-status error" + : "project-browser-status"; +} + +function browserDirectoryPickerAvailable() { + return typeof window.showDirectoryPicker === "function"; +} + function openProjectDialog(action) { if (!els.projectDialog) { return; @@ -1085,14 +1217,52 @@ function openProjectDialog(action) { ? "Save the active schema and data to a server-side project folder." : "Load schema and data from a server-side project folder or projectfile.erproj."; els.projectDialogWarning.textContent = isSave - ? "Saving overwrites projectfile.erproj, schema.yaml, and data.json in the selected folder. Other files are left alone." + ? "Saving overwrites managed project files in the selected folder. Other files are left alone." : "Loading a project replaces the current workspace schema and data."; els.projectDialogConfirm.textContent = isSave ? "Save Project" : "Load Project"; + syncProjectSaveStorageOptions(isSave); + if (els.projectBrowserSaveButton) { + els.projectBrowserSaveButton.hidden = !isSave; + els.projectBrowserSaveButton.disabled = !browserDirectoryPickerAvailable(); + } + if (els.projectBrowserLoadButton) { + els.projectBrowserLoadButton.hidden = isSave; + els.projectBrowserLoadButton.disabled = !browserDirectoryPickerAvailable(); + } + setProjectDialogStatus( + browserDirectoryPickerAvailable() + ? "" + : "Browser folder picker is not available in this browser; use Browse or type a path."); + renderProjectRecentPaths(); els.projectPathInput.value = currentPath; els.projectDialog.hidden = false; els.projectPathInput.focus(); } +function syncProjectSaveStorageOptions(isSave) { + if (!els.projectSaveStorageOptions) { + return; + } + els.projectSaveStorageOptions.hidden = !isSave; + if (!isSave) { + return; + } + const storage = state.workspace && state.workspace.storage ? state.workspace.storage : {}; + const saveAsSqlite = storage.mode === "sqlite-file"; + if (els.projectSaveStorageJson) { + els.projectSaveStorageJson.checked = !saveAsSqlite; + } + if (els.projectSaveStorageSqlite) { + els.projectSaveStorageSqlite.checked = saveAsSqlite; + } +} + +function selectedProjectStorageMode() { + return els.projectSaveStorageSqlite && els.projectSaveStorageSqlite.checked + ? "sqlite" + : "json"; +} + function closeProjectDialog() { if (!els.projectDialog) { return; @@ -1101,6 +1271,52 @@ function closeProjectDialog() { els.projectDialog.hidden = true; } +async function browseProjectPath() { + if (!state.projectDialogAction || !els.projectPathInput) { + return; + } + if (els.projectBrowseButton) { + els.projectBrowseButton.disabled = true; + } + setProjectDialogStatus("Waiting for native project browser..."); + try { + const selection = await requestJson("/ui/project/browse", { + method: "POST", + body: JSON.stringify({ + action: state.projectDialogAction, + path: els.projectPathInput.value.trim() + }) + }); + setProjectDialogStatus(selection.message || ""); + if (selection.selected && selection.path) { + els.projectPathInput.value = selection.path; + rememberProjectPath(selection.path); + await validateProjectPath(); + } + } finally { + if (els.projectBrowseButton) { + els.projectBrowseButton.disabled = false; + } + } +} + +async function validateProjectPath() { + if (!state.projectDialogAction || !els.projectPathInput) { + return; + } + const path = els.projectPathInput.value.trim(); + if (!path) { + setProjectDialogStatus("Enter a project folder path.", true); + return; + } + const result = await requestJson("/ui/project/check", { + method: "POST", + body: JSON.stringify({action: state.projectDialogAction, path}) + }); + const text = result.warning || result.message || "Project path checked."; + setProjectDialogStatus(text, !result.canProceed); +} + async function submitProjectDialog() { if (!state.projectDialogAction || !els.projectPathInput) { return; @@ -1112,18 +1328,60 @@ async function submitProjectDialog() { } const action = state.projectDialogAction; const endpoint = action === "save" ? "/ui/project/save" : "/ui/project/load"; + const requestBody = action === "save" + ? {path, projectStorageMode: selectedProjectStorageMode()} + : {path}; const workspace = await requestJson(endpoint, { method: "POST", - body: JSON.stringify({path}) + body: JSON.stringify(requestBody) }); state.workspace = workspace; state.schemaDraft = null; state.schemaPreview = null; + rememberProjectPath(path); closeProjectDialog(); await loadWorkspace(); showMessage(action === "save" ? "Project saved." : "Project loaded."); } +function syncStorageControlsFromWorkspace() { + if (!els.storageModeSelect) { + return; + } + const storage = state.workspace && state.workspace.storage ? state.workspace.storage : {}; + els.storageModeSelect.value = storage.mode || "memory"; + if (els.storageFileInput) { + els.storageFileInput.value = storage.sqliteFile || ""; + } + updateStorageFileInputAvailability(); +} + +function updateStorageFileInputAvailability() { + if (!els.storageModeSelect || !els.storageFileInput) { + return; + } + els.storageFileInput.disabled = els.storageModeSelect.value !== "sqlite-file"; +} + +async function switchStorage() { + if (!els.storageModeSelect) { + return; + } + const mode = els.storageModeSelect.value; + const sqliteFile = els.storageFileInput ? els.storageFileInput.value.trim() : ""; + if (mode === "sqlite-file" && !sqliteFile) { + showMessage("Enter a SQLite file path.", true); + return; + } + const workspace = await requestJson("/ui/storage/switch", { + method: "POST", + body: JSON.stringify({mode, sqliteFile}) + }); + state.workspace = workspace; + await loadWorkspace(); + showMessage("Storage switched."); +} + function markSchemaDirty() { if (state.mode !== "schema") { return; @@ -1309,6 +1567,7 @@ function renderSchemaFieldTree(container, entityIndex, fields, parentPath) { function schemaTreeSection(title, addAction) { const section = document.createElement("section"); section.className = "schema-tree-section"; + setTestId(section, testIdFor("schema-section", title)); const heading = document.createElement("div"); heading.className = "schema-tree-heading"; const text = document.createElement("span"); @@ -1325,6 +1584,7 @@ function schemaTreeNode(label, selection, meta) { const button = document.createElement("button"); button.type = "button"; button.className = `schema-tree-node ${schemaSelectionMatches(selection) ? "active" : ""}`; + setTestId(button, testIdFor("schema-tree", schemaSelectionKey(selection), label)); const labelSpan = document.createElement("span"); labelSpan.className = "tree-node-label"; labelSpan.textContent = label; @@ -1646,6 +1906,7 @@ function wrapSchemaDetail(primary, single, secondary) { function schemaCard(title) { const card = document.createElement("section"); card.className = "schema-card"; + setTestId(card, testIdFor("schema-card", title)); const heading = document.createElement("h3"); heading.textContent = title; card.appendChild(heading); @@ -1869,6 +2130,7 @@ function renderSchemaUpgradePanel() { function upgradeMappingPanel() { const panel = document.createElement("div"); panel.className = "schema-upgrade-card"; + setTestId(panel, "schema-upgrade-mappings"); panel.appendChild(sectionHeading("Migration Mappings")); panel.appendChild(upgradeEntityMappings()); panel.appendChild(upgradeFieldMappings()); @@ -1879,6 +2141,7 @@ function upgradeMappingPanel() { function upgradeReportPanel() { const panel = document.createElement("div"); panel.className = "schema-upgrade-card"; + setTestId(panel, "schema-upgrade-report"); panel.appendChild(sectionHeading("Upgrade Preview")); const preview = state.schemaUpgradePreview; if (!preview) { @@ -1989,10 +2252,12 @@ function upgradeRelationshipMappings() { function upgradeSelectRow(labelText, value, options, emptyLabel, onChange) { const row = document.createElement("label"); row.className = "schema-upgrade-row"; + setTestId(row, testIdFor("schema-upgrade-row", labelText)); const label = document.createElement("span"); label.textContent = labelText; row.appendChild(label); const select = document.createElement("select"); + setTestId(select, testIdFor("schema-upgrade-select", labelText)); options.forEach(optionValue => { const option = document.createElement("option"); option.value = optionValue; @@ -2261,6 +2526,8 @@ function schemaTextControl(labelText, value, onInput, type = "text", helpText = input.type = type; } input.value = value === undefined || value === null ? "" : value; + setTestId(label, testIdFor("schema-control", labelText || "blank")); + setTestId(input, testIdFor("schema-input", labelText || "blank")); input.addEventListener("input", () => onInput(input.value)); label.appendChild(input); return label; @@ -2269,6 +2536,8 @@ function schemaTextControl(labelText, value, onInput, type = "text", helpText = function schemaSelectControl(labelText, value, options, onChange, helpText = "") { const label = schemaControlLabel(labelText, helpText); const select = document.createElement("select"); + setTestId(label, testIdFor("schema-control", labelText || "blank")); + setTestId(select, testIdFor("schema-input", labelText || "blank")); const selectedValue = value === undefined || value === null ? "" : String(value); if (!options.includes(selectedValue)) { options = [selectedValue, ...options].filter((item, index, list) => item || index === list.indexOf(item)); @@ -2290,6 +2559,8 @@ function schemaCheckboxControl(labelText, value, onChange, helpText = "") { const input = document.createElement("input"); input.type = "checkbox"; input.checked = Boolean(value); + setTestId(label, testIdFor("schema-control", labelText || "blank")); + setTestId(input, testIdFor("schema-input", labelText || "blank")); input.addEventListener("change", () => onChange(input.checked)); label.appendChild(input); return label; @@ -2320,6 +2591,7 @@ function schemaActionButton(text, onClick, className = "compact-button", helpTex button.type = "button"; button.className = className; button.textContent = text; + setTestId(button, testIdFor("schema-action", text)); if (helpText) { button.setAttribute("data-tippy-content", helpText); } @@ -2428,6 +2700,101 @@ function downloadText(filename, text, type) { URL.revokeObjectURL(url); } +async function browserSaveProject() { + if (!browserDirectoryPickerAvailable()) { + setProjectDialogStatus("Browser folder picker is not available in this browser.", true); + return; + } + const directory = await window.showDirectoryPicker({mode: "readwrite"}); + if (els.projectPathInput) { + els.projectPathInput.value = directory.name || ""; + } + const project = await requestJson("/ui/project/export-files", { + method: "POST", + body: JSON.stringify({projectStorageMode: selectedProjectStorageMode()}) + }); + for (const file of project.files || []) { + const handle = await directory.getFileHandle(file.name, {create: true}); + const writable = await handle.createWritable(); + if (file.type === "base64") { + await writable.write(base64ToBytes(file.content || "")); + } else { + await writable.write(file.content || ""); + } + await writable.close(); + } + setProjectDialogStatus(`Browser project saved: ${directory.name}`); + showMessage(`Browser project saved: ${directory.name}`); +} + +async function browserLoadProject() { + if (!browserDirectoryPickerAvailable()) { + setProjectDialogStatus("Browser folder picker is not available in this browser.", true); + return; + } + const directory = await window.showDirectoryPicker(); + const files = []; + await addTextProjectFile(files, directory, "projectfile.erproj", true); + await addTextProjectFile(files, directory, "schema.yaml", true); + await addTextProjectFile(files, directory, "data.json", false); + await addBinaryProjectFile(files, directory, "data.sqlite", false); + const workspace = await requestJson("/ui/project/load-files", { + method: "POST", + body: JSON.stringify({folderName: directory.name, files}) + }); + state.workspace = workspace; + state.schemaDraft = null; + state.schemaPreview = null; + closeProjectDialog(); + await loadWorkspace(); + showMessage(`Browser project loaded: ${directory.name}`); +} + +async function addTextProjectFile(files, directory, name, required) { + const file = await projectFileFromDirectory(directory, name, required); + if (!file) { + return; + } + files.push({name, type: "text", content: await file.text()}); +} + +async function addBinaryProjectFile(files, directory, name, required) { + const file = await projectFileFromDirectory(directory, name, required); + if (!file) { + return; + } + files.push({name, type: "base64", content: bytesToBase64(new Uint8Array(await file.arrayBuffer()))}); +} + +async function projectFileFromDirectory(directory, name, required) { + try { + const handle = await directory.getFileHandle(name); + return await handle.getFile(); + } catch (error) { + if (required) { + throw new Error(`Browser project folder must contain ${name}.`); + } + return null; + } +} + +function bytesToBase64(bytes) { + let binary = ""; + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + return btoa(binary); +} + +function base64ToBytes(base64) { + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index++) { + bytes[index] = binary.charCodeAt(index); + } + return bytes; +} + async function copySchemaOutput(kind) { if (!state.schemaPreview || !state.schemaPreview.valid) { showMessage("Schema must be valid before copying.", true); @@ -2675,11 +3042,39 @@ if (els.saveProjectButton) { if (els.loadProjectButton) { els.loadProjectButton.addEventListener("click", () => openProjectDialog("load")); } +if (els.storageModeSelect) { + els.storageModeSelect.addEventListener("change", updateStorageFileInputAvailability); +} +if (els.switchStorageButton) { + els.switchStorageButton.addEventListener("click", () => { + switchStorage().catch(error => showMessage(error.message, true)); + }); +} if (els.projectDialogConfirm) { els.projectDialogConfirm.addEventListener("click", () => { submitProjectDialog().catch(error => showMessage(error.message, true)); }); } +if (els.projectBrowseButton) { + els.projectBrowseButton.addEventListener("click", () => { + browseProjectPath().catch(error => setProjectDialogStatus(error.message, true)); + }); +} +if (els.projectValidateButton) { + els.projectValidateButton.addEventListener("click", () => { + validateProjectPath().catch(error => setProjectDialogStatus(error.message, true)); + }); +} +if (els.projectBrowserSaveButton) { + els.projectBrowserSaveButton.addEventListener("click", () => { + browserSaveProject().catch(error => setProjectDialogStatus(error.message, true)); + }); +} +if (els.projectBrowserLoadButton) { + els.projectBrowserLoadButton.addEventListener("click", () => { + browserLoadProject().catch(error => setProjectDialogStatus(error.message, true)); + }); +} if (els.projectDialogCancel) { els.projectDialogCancel.addEventListener("click", closeProjectDialog); } diff --git a/thingifier-crud-ui/src/main/resources/public/assets/styles.css b/thingifier-crud-ui/src/main/resources/public/assets/styles.css index 58427ac6..2e66e2b4 100644 --- a/thingifier-crud-ui/src/main/resources/public/assets/styles.css +++ b/thingifier-crud-ui/src/main/resources/public/assets/styles.css @@ -200,6 +200,28 @@ button.danger { cursor: pointer; } +.storage-controls { + display: inline-flex; + align-items: center; + gap: 6px; + padding-left: 8px; + border-left: 1px solid var(--line); +} + +.storage-controls select, +.storage-controls input { + min-height: 32px; +} + +.storage-controls input { + width: 190px; +} + +.storage-controls input:disabled { + background: #f0f2f5; + color: var(--muted); +} + .workspace { flex: 1; min-height: 0; @@ -653,6 +675,61 @@ button.danger { padding: 7px 9px; } +.field-row .project-path-controls { + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto; + gap: 8px; + align-items: center; + color: var(--ink); + font-size: 14px; + font-weight: 400; + text-transform: none; +} + +.project-save-storage-options { + display: grid; + gap: 8px; + margin: 12px 0 0; + padding: 10px; + border: 1px solid var(--line); + border-radius: 4px; +} + +.project-save-storage-options legend { + color: var(--muted); + font-size: 12px; + font-weight: 700; + text-transform: uppercase; +} + +.project-save-storage-options label { + display: flex; + align-items: center; + gap: 8px; +} + +.project-save-storage-options input { + width: auto; +} + +.project-browser-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; + margin-top: 12px; +} + +.project-browser-status { + min-height: 18px; + margin: 10px 0 0; + color: var(--muted); + line-height: 1.4; +} + +.project-browser-status.error { + color: var(--danger); +} + .project-dialog-warning { margin: 12px 0 0; color: var(--muted); diff --git a/thingifier-crud-ui/src/main/resources/public/index.html b/thingifier-crud-ui/src/main/resources/public/index.html index fddac7b2..a4a25d09 100644 --- a/thingifier-crud-ui/src/main/resources/public/index.html +++ b/thingifier-crud-ui/src/main/resources/public/index.html @@ -8,54 +8,90 @@ -
+
-

Thingifier

-

+

Thingifier

+

- -
- diff --git a/thingifier-crud-ui/src/main/resources/public/schema.html b/thingifier-crud-ui/src/main/resources/public/schema.html index 0067670b..df00c907 100644 --- a/thingifier-crud-ui/src/main/resources/public/schema.html +++ b/thingifier-crud-ui/src/main/resources/public/schema.html @@ -9,30 +9,30 @@ -
+
-

Schema Edit

-

+

Schema Edit

+

- - - - - - - + + + + + + +
- + -
+
@@ -40,16 +40,16 @@

ER Diagram

Rendered from the current draft.

- - - - - + + + + +
-
-
-
+
+
+
Relationship Key

ER Diagram

- -
+ +
- Select schema item + Select schema item
-
+
-
-