diff --git a/knowledge-layout/src/main/java/dev/ikm/komet/layout/editor/StandardEditorWindows.java b/knowledge-layout/src/main/java/dev/ikm/komet/layout/editor/StandardEditorWindows.java index 955b0403a..6f8df5e7d 100644 --- a/knowledge-layout/src/main/java/dev/ikm/komet/layout/editor/StandardEditorWindows.java +++ b/knowledge-layout/src/main/java/dev/ikm/komet/layout/editor/StandardEditorWindows.java @@ -27,6 +27,7 @@ import java.util.prefs.BackingStoreException; import static dev.ikm.komet.preferences.KLEditorPreferences.KL_EDITOR_WINDOWS; +import static dev.ikm.komet.preferences.KLEditorPreferences.KL_MAIN_SECTION; /** * Seeds the standard (application-provided) KL Windows into the standard-windows folder of the @@ -82,15 +83,24 @@ public static void ensureStandardWindows(KometPreferences standardWindowsPrefere standardWindowsPreferences.putInt(STANDARD_WINDOWS_VERSION_KEY, CURRENT_STANDARD_WINDOWS_VERSION); } - List standardWindows = standardWindowsPreferences.getList(KL_EDITOR_WINDOWS); - if (!standardWindows.contains(CONCEPT_WINDOW_2)) { + if (!isSaved(standardWindowsPreferences, CONCEPT_WINDOW_2)) { saveConceptWindow2(standardWindowsPreferences, viewCalculator); } - if (!standardWindows.contains(PATTERN_WINDOW_2)) { + if (!isSaved(standardWindowsPreferences, PATTERN_WINDOW_2)) { savePatternWindow2(standardWindowsPreferences, viewCalculator); } } + /** + * Whether the window is both listed and actually written. The list alone is not enough: a + * seeding that failed part way can leave the title listed with no window definition behind it, + * which then fails every load with "Can't load section (main section) from preferences". + */ + private static boolean isSaved(KometPreferences standardWindowsPreferences, String windowTitle) { + return standardWindowsPreferences.getList(KL_EDITOR_WINDOWS).contains(windowTitle) + && standardWindowsPreferences.node(windowTitle).get(KL_MAIN_SECTION).isPresent(); + } + /** * Removes every seeded standard window definition (their window nodes and the window list), * so the seeding in {@link #ensureStandardWindows} re-creates them from the current code. diff --git a/kview/src/main/java/dev/ikm/komet/kview/fxutils/ComboBoxHelper.java b/kview/src/main/java/dev/ikm/komet/kview/fxutils/ComboBoxHelper.java index de25d69fc..7c3921f02 100644 --- a/kview/src/main/java/dev/ikm/komet/kview/fxutils/ComboBoxHelper.java +++ b/kview/src/main/java/dev/ikm/komet/kview/fxutils/ComboBoxHelper.java @@ -118,6 +118,9 @@ public T fromString(String string) { // TODO: might be another way to fix by using CSS to fix this issue or examine height of popup (ListView). Platform.runLater(() -> { final Node scrollBar = comboBox.lookup(".scroll-bar:vertical"); + if (scrollBar == null) { + return; // no popup scroll bar to hide (yet) — nothing to work around + } scrollBar.setStyle("-fx-pref-width: 0; -fx-padding: 0;"); scrollBar.applyCss(); scrollBar.setVisible(false); diff --git a/kview/src/main/java/dev/ikm/komet/kview/mvvm/view/changeset/ExportController.java b/kview/src/main/java/dev/ikm/komet/kview/mvvm/view/changeset/ExportController.java index 368ecf6f3..f98d4b1c4 100644 --- a/kview/src/main/java/dev/ikm/komet/kview/mvvm/view/changeset/ExportController.java +++ b/kview/src/main/java/dev/ikm/komet/kview/mvvm/view/changeset/ExportController.java @@ -29,6 +29,7 @@ import dev.ikm.komet.kview.mvvm.viewmodel.ExportViewModel; import dev.ikm.tinkar.common.alert.AlertStreams; import dev.ikm.tinkar.coordinate.view.calculator.ViewCalculator; +import dev.ikm.tinkar.common.service.TrackingCallable; import dev.ikm.tinkar.entity.export.ExportEntitiesToProtobufFile; import dev.ikm.tinkar.terms.EntityFacade; import javafx.beans.InvalidationListener; @@ -209,7 +210,12 @@ private void setUpPathOptions() { @Override public String toString(EntityFacade conceptEntity) { ViewCalculator viewCalculator = getViewProperties().calculator(); - return (conceptEntity != null) ? viewCalculator.getRegularDescriptionText(conceptEntity).get() : ""; + // orElseGet, not get(): over gRPC a path's descriptions may not be loaded locally + // yet, and an empty Optional here threw while the dialog was being built. + return (conceptEntity != null) + ? viewCalculator.getRegularDescriptionText(conceptEntity) + .orElseGet(() -> viewCalculator.getPreferredDescriptionTextWithFallbackOrNid(conceptEntity)) + : ""; } @Override @@ -347,7 +353,14 @@ private void performChangeSetExport(final FileSavePicker fileSavePicker, final l return CompletableFuture.failedFuture(new IllegalArgumentException("Export file cannot be null")); } - ExportEntitiesToProtobufFile exportEntities = new ExportEntitiesToProtobufFile(exportFile, fromDate, toDate); + // Connected to a server: export from there, where all the data is, rather than from + // this Komet's local view, which holds only what it has loaded. + TrackingCallable exportEntities = RemoteChangesetTask.remote() + .>map(remote -> + new RemoteChangesetTask(remote, "Export change set from the server", + (service, listener, tracker) -> + service.exportChangeSet(exportFile, fromDate, toDate, listener, tracker))) + .orElseGet(() -> new ExportEntitiesToProtobufFile(exportFile, fromDate, toDate)); CompletableFuture exportFuture = ProgressHelper.progress(exportEntities, "Cancel Export"); exportFuture.handle((result, throwable) -> { @@ -381,7 +394,12 @@ private void performMembershipSetExport(final FileSavePicker fileSavePicker) { // map TagsDataModel to a publicId EntityHandle.get(Integer.parseInt(tagsDataModel.tagNid)).expectEntity().publicId() ).toList(); - ExportEntitiesToProtobufFile exportEntities = new ExportEntitiesToProtobufFile(exportFile, membershipPublicIds); + TrackingCallable exportEntities = RemoteChangesetTask.remote() + .>map(remote -> + new RemoteChangesetTask(remote, "Export tagged modules from the server", + (service, listener, tracker) -> + service.exportMembership(exportFile, membershipPublicIds, listener, tracker))) + .orElseGet(() -> new ExportEntitiesToProtobufFile(exportFile, membershipPublicIds)); CompletableFuture exportFuture = ProgressHelper.progress(exportEntities, "Cancel Export"); exportFuture.handle((result, throwable) -> { diff --git a/kview/src/main/java/dev/ikm/komet/kview/mvvm/view/changeset/ImportController.java b/kview/src/main/java/dev/ikm/komet/kview/mvvm/view/changeset/ImportController.java index 481c8bde2..3138c75f3 100644 --- a/kview/src/main/java/dev/ikm/komet/kview/mvvm/view/changeset/ImportController.java +++ b/kview/src/main/java/dev/ikm/komet/kview/mvvm/view/changeset/ImportController.java @@ -28,6 +28,7 @@ import dev.ikm.komet.kview.mvvm.viewmodel.ImportViewModel; import dev.ikm.tinkar.common.alert.AlertStreams; import dev.ikm.tinkar.common.service.EntityCountSummary; +import dev.ikm.tinkar.common.service.TrackingCallable; import dev.ikm.tinkar.entity.load.LoadEntitiesFromProtobufFile; import javafx.application.Platform; import javafx.css.PseudoClass; @@ -216,7 +217,13 @@ void handleImportButtonEvent(ActionEvent event) { if (importViewModel.validProperty().get()) { File selectedFile = importViewModel.getPropertyValue(SELECTED_FILE); - LoadEntitiesFromProtobufFile importTask = new LoadEntitiesFromProtobufFile(selectedFile); + // Connected to a server: import there, where the data lives, rather than into this + // Komet's local view of it. + TrackingCallable importTask = RemoteChangesetTask.remote() + .>map(remote -> new RemoteChangesetTask(remote, + "Import " + selectedFile.getName() + " on the server", + (service, listener, tracker) -> service.importChangeset(selectedFile, listener, tracker))) + .orElseGet(() -> new LoadEntitiesFromProtobufFile(selectedFile)); FrameworkTopics destTopic = importViewModel.getPropertyValue(DESTINATION_TOPIC); CompletableFuture future = ProgressHelper.progress(destTopic, importTask, "Cancel Import"); future.whenComplete((entityCountSummary, throwable) -> { diff --git a/kview/src/main/java/dev/ikm/komet/kview/mvvm/view/changeset/RemoteChangesetTask.java b/kview/src/main/java/dev/ikm/komet/kview/mvvm/view/changeset/RemoteChangesetTask.java new file mode 100644 index 000000000..3455bfbed --- /dev/null +++ b/kview/src/main/java/dev/ikm/komet/kview/mvvm/view/changeset/RemoteChangesetTask.java @@ -0,0 +1,72 @@ +/* + * Copyright © 2015 Integrated Knowledge Management (support@ikm.dev) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package dev.ikm.komet.kview.mvvm.view.changeset; + +import dev.ikm.tinkar.common.service.EntityCountSummary; +import dev.ikm.tinkar.common.service.RemoteChangesetService; +import dev.ikm.tinkar.common.service.ServiceLifecycleManager; +import dev.ikm.tinkar.common.service.TrackingCallable; + +import java.util.Optional; + +/** + * Runs a changeset import or export on the remote datastore, for Komet connected over gRPC. + * + *

In that mode Komet's own store is only a local view of the server's: importing into it would + * be lost on close and seen by nobody else, and exporting from it would cover only what Komet had + * loaded. So the import and export dialogs hand the work to the server whenever a + * {@link RemoteChangesetService} is running, and to the local loader and exporter otherwise. + */ +final class RemoteChangesetTask extends TrackingCallable { + + /** The remote call to make, given where to report progress and the handle that cancels it. */ + @FunctionalInterface + interface Work { + EntityCountSummary run(RemoteChangesetService service, RemoteChangesetService.ProgressListener listener, + TrackingCallable tracker); + } + + private final RemoteChangesetService service; + private final Work work; + + RemoteChangesetTask(RemoteChangesetService service, String title, Work work) { + super(true, true); + this.service = service; + this.work = work; + updateTitle(title); + } + + /** The remote service, when Komet is connected to one. */ + static Optional remote() { + return ServiceLifecycleManager.get().getRunningService(RemoteChangesetService.class); + } + + @Override + protected EntityCountSummary compute() { + updateProgress(-1, 1); + EntityCountSummary result = work.run(service, (done, total, message) -> { + if (message != null && !message.isBlank()) { + updateMessage(message); + } + if (total > 1 && done >= 0) { + updateProgress(done, total); + } + }, this); + updateProgress(1, 1); + updateMessage("Done on the server in " + durationString()); + return result; + } +} diff --git a/kview/src/main/java/dev/ikm/komet/kview/mvvm/view/search/SortResultConceptEntryController.java b/kview/src/main/java/dev/ikm/komet/kview/mvvm/view/search/SortResultConceptEntryController.java index f65e7d75d..28874dedf 100644 --- a/kview/src/main/java/dev/ikm/komet/kview/mvvm/view/search/SortResultConceptEntryController.java +++ b/kview/src/main/java/dev/ikm/komet/kview/mvvm/view/search/SortResultConceptEntryController.java @@ -137,9 +137,15 @@ public void initialize() { patternEntity, StandardEditorWindows.PATTERN_WINDOW_2)); } } else if (remotePublicIds != null && !remotePublicIds.isEmpty()) { - // Remote-backed result: fetch full entity graph from the server, load into - // ephemeral store, then open the concept window as normal. - openRemoteConcept(); + // Remote-backed result: fetch the concept from the server first, then open + // the same windows a local result does — Concept (2), or classic with shift. + boolean classic = mouseEvent.isShiftDown(); + withConcept(conceptEntity -> eventBus.publish( + searchEntryViewModel.getPropertyValue(CURRENT_JOURNAL_WINDOW_TOPIC), + classic + ? new MakeConceptWindowEvent(this, MakeConceptWindowEvent.OPEN_CONCEPT_FROM_CONCEPT, conceptEntity) + : new MakeKLWindowEvent(this, MakeKLWindowEvent.OPEN_STANDARD_WINDOW, + conceptEntity, StandardEditorWindows.CONCEPT_WINDOW_2))); } } } @@ -153,24 +159,65 @@ public void initialize() { } public void populateConcept(ActionEvent actionEvent) { - if (entity instanceof ConceptEntity conceptEntity) { - eventBus.publish(searchEntryViewModel.getPropertyValue(CURRENT_JOURNAL_WINDOW_TOPIC), new MakeConceptWindowEvent(this, - MakeConceptWindowEvent.OPEN_CONCEPT_FROM_CONCEPT, conceptEntity)); - } + withConcept(conceptEntity -> eventBus.publish(searchEntryViewModel.getPropertyValue(CURRENT_JOURNAL_WINDOW_TOPIC), + new MakeConceptWindowEvent(this, MakeConceptWindowEvent.OPEN_CONCEPT_FROM_CONCEPT, conceptEntity))); } public void openInConceptNavigator(ActionEvent actionEvent) { - if (entity instanceof ConceptEntity conceptEntity) { - eventBus.publish(searchEntryViewModel.getPropertyValue(CURRENT_JOURNAL_WINDOW_TOPIC), new ShowNavigationalPanelEvent(this, ShowNavigationalPanelEvent.SHOW_CONCEPT_NAVIGATIONAL_FROM_CONCEPT, conceptEntity)); - } + withConcept(conceptEntity -> eventBus.publish(searchEntryViewModel.getPropertyValue(CURRENT_JOURNAL_WINDOW_TOPIC), + new ShowNavigationalPanelEvent(this, ShowNavigationalPanelEvent.SHOW_CONCEPT_NAVIGATIONAL_FROM_CONCEPT, conceptEntity))); } public void openAsKLWindow(ActionEvent actionEvent, String windowTitle) { UUID journalTopic = searchEntryViewModel.getPropertyValue(CURRENT_JOURNAL_WINDOW_TOPIC); + if (entity == null) { + withConcept(conceptEntity -> eventBus.publish(journalTopic, + new MakeKLWindowEvent(this, MakeKLWindowEvent.OPEN_ENTITY_FROM_ENTITY, conceptEntity, windowTitle))); + return; + } eventBus.publish(journalTopic, new MakeKLWindowEvent(this, MakeKLWindowEvent.OPEN_ENTITY_FROM_ENTITY, entity, windowTitle)); } + /** + * Runs {@code action} with this result's concept on the UI thread. + * + *

A local result has its entity already. A result from a remote search (Komet connected over + * gRPC) carries only the concept's UUIDs, so the concept is fetched from the server into the + * local store first — every action on the row works the same either way, where before only a + * double-click fetched it and the menu actions silently did nothing. The fetched concept is kept, + * so later actions on the same row do not fetch again. + */ + private void withConcept(java.util.function.Consumer action) { + if (entity instanceof ConceptEntity conceptEntity) { + action.accept(conceptEntity); + return; + } + if (remotePublicIds == null || remotePublicIds.isEmpty()) { + return; + } + List ids = List.copyOf(remotePublicIds); + Thread.ofVirtual().start(() -> { + try { + RemoteConceptSearchService remote = ServiceLifecycleManager.get() + .getRunningService(RemoteConceptSearchService.class) + .orElseThrow(() -> new IllegalStateException("RemoteConceptSearchService not available")); + long nid = remote.loadConceptWithSemantics(ids); + Entity loaded = EntityHandle.get(nid).orNull(); + if (loaded instanceof ConceptEntity loadedConcept) { + Platform.runLater(() -> { + entity = loadedConcept; + action.accept(loadedConcept); + }); + } else { + LOG.warn("Loaded entity for {} is not a ConceptEntity: {}", ids, loaded); + } + } catch (Exception ex) { + LOG.warn("Failed to load concept details from remote backend for {}: {}", ids, ex.getMessage()); + } + }); + } + public boolean isRetired() { return retired; } @@ -249,36 +296,6 @@ public void setRemotePublicIds(List publicIds) { this.remotePublicIds = publicIds; } - /** - * Background-fetches the concept entity graph from the active {@link RemoteConceptSearchService}, - * loads it into the local ephemeral entity store, then fires {@link MakeConceptWindowEvent} - * on the UI thread. - */ - private void openRemoteConcept() { - List ids = List.copyOf(remotePublicIds); - UUID journalTopic = searchEntryViewModel.getPropertyValue(CURRENT_JOURNAL_WINDOW_TOPIC); - Thread.ofVirtual().start(() -> { - try { - RemoteConceptSearchService remote = ServiceLifecycleManager.get() - .getRunningService(RemoteConceptSearchService.class) - .orElseThrow(() -> new IllegalStateException("RemoteConceptSearchService not available")); - long nid = remote.loadConceptWithSemantics(ids); - Entity loaded = EntityHandle.get(nid).orNull(); - if (loaded instanceof ConceptEntity loadedConcept) { - Platform.runLater(() -> - eventBus.publish(journalTopic, - new MakeConceptWindowEvent(this, - MakeConceptWindowEvent.OPEN_CONCEPT_FROM_CONCEPT, - loadedConcept))); - } else { - LOG.warn("Loaded entity for {} is not a ConceptEntity: {}", ids, loaded); - } - } catch (Exception ex) { - LOG.warn("Failed to load concept details from remote backend for {}: {}", ids, ex.getMessage()); - } - }); - } - public void setWindowView(ObservableViewNoOverride windowView) { this.windowView = windowView; } diff --git a/pom.xml b/pom.xml index 6e6b4405b..1cf1d6056 100644 --- a/pom.xml +++ b/pom.xml @@ -11,7 +11,7 @@ dev.ikm.komet komet-parent KOMET - 1.59.5-SNAPSHOT + 1.59.5-changeset_import_export-SNAPSHOT pom 2015