diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 2e999c63c35d..e036486479b9 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -1,6 +1,7 @@ name: "Bug report" description: Create a report to help us improve type: Bug +labels: ["status: needs-triage"] body: - type: markdown attributes: diff --git a/.github/ISSUE_TEMPLATE/suggestion-for-improvement.md b/.github/ISSUE_TEMPLATE/suggestion-for-improvement.md index fe7db23f79be..8d89047c46fe 100644 --- a/.github/ISSUE_TEMPLATE/suggestion-for-improvement.md +++ b/.github/ISSUE_TEMPLATE/suggestion-for-improvement.md @@ -2,7 +2,7 @@ name: Suggestion for improvement about: Suggest an enhancement title: '' -labels: '' +labels: 'status: needs-triage' type: 'feature' assignees: '' diff --git a/.github/workflows/on-issue-comment.yml b/.github/workflows/on-issue-comment.yml index 100bba53aab3..dfb30906a834 100644 --- a/.github/workflows/on-issue-comment.yml +++ b/.github/workflows/on-issue-comment.yml @@ -12,8 +12,16 @@ jobs: contents: read issues: write steps: + - name: Decline self-assignment of untriaged issues + id: untriaged + if: "${{ contains(github.event.comment.body, '/assign-me') && contains(github.event.issue.labels.*.name, 'status: needs-triage') }}" + run: | + gh issue comment "${{ github.event.issue.html_url }}" --body "Hi @${{ github.event.comment.user.login }}, this issue needs triage by a maintainer first. Please wait until the \`status: needs-triage\` label is removed." + env: + GH_TOKEN: ${{ github.token }} - name: Assign the user or unassign stale assignments id: assign + if: ${{ steps.untriaged.outcome == 'skipped' }} uses: takanome-dev/assign-issue-action@fad4b83750f7df31e03c2a42e8f1880b0b4b3573 # edge with: github_token: '${{ secrets.GH_TOKEN_ASSIGN_ISSUE_ACTION || github.token }}' diff --git a/CHANGELOG.md b/CHANGELOG.md index e2da4e5c6734..aa98274d604b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,12 +12,16 @@ Note that this project **does not** adhere to [Semantic Versioning](https://semv ### Added - We added `jabkit git merge-driver`, a Git merge driver that merges `.bib` files semantically. [#16838](https://github.com/JabRef/jabref/pull/16838) +- We added "Help > View background tasks" and clearer "Quit anyway"/"Keep JabRef open" buttons when quitting during background tasks. [#12706](https://github.com/JabRef/jabref/issues/12706) ### Changed ### Fixed +- We re-enabled the [Grobid](https://github.com/grobidOrg/grobid) citation fetcher again. It is now reachable via https. [#16668](https://github.com/JabRef/jabref/issues/16668) - We fixed an issue where the AI chat lost its scroll position when switching back to an entry. [#17172](https://github.com/JabRef/jabref/pull/17172) +- We fixed an issue where pressing "+" in the "File" field with an automatically found file selected opened the "Add file link" dialog instead of linking the selected file. [#16938](https://github.com/JabRef/jabref/pull/16938) +- We fixed an issue where case-sensitive search (`=!`, `==!`, `=~!`) in linked files ignored the casing and matched text in any casing. [#13048](https://github.com/JabRef/jabref/issues/13048) ### Removed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 997f0adaa51c..2a8f094bcd0e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -83,6 +83,7 @@ One can also add [callouts](https://just-the-docs.github.io/just-the-docs-tests/ Comment on the issue you want to work at with `/assign-me`. GitHub will then automatically assign you. +Issues labeled `status: needs-triage` cannot be self-assigned until a maintainer has triaged them. Before you do so, read the complete issue and its discussion. Some issues state a prerequisite - typically another pull request that has to be merged first. diff --git a/build-logic/build.gradle.kts b/build-logic/build.gradle.kts index d6d35888b2e0..323bd22345c9 100644 --- a/build-logic/build.gradle.kts +++ b/build-logic/build.gradle.kts @@ -28,9 +28,9 @@ dependencies { implementation("org.gradlex:java-module-testing:1.8.1") implementation("org.gradlex:jvm-dependency-conflict-resolution:2.5") implementation("org.gradle.toolchains:foojay-resolver:1.0.0") - implementation("org.itsallcode:openfasttrace-gradle:3.2.0") - implementation("org.itsallcode.openfasttrace:openfasttrace-api:4.9.0") - implementation("org.itsallcode.openfasttrace:openfasttrace-core:4.9.0") - implementation("org.itsallcode.openfasttrace:openfasttrace-exporter-specobject:4.9.0") - implementation("org.itsallcode.openfasttrace:openfasttrace:4.9.0") + implementation("org.itsallcode:openfasttrace-gradle:3.4.0") + implementation("org.itsallcode.openfasttrace:openfasttrace-api:4.10.0") + implementation("org.itsallcode.openfasttrace:openfasttrace-core:4.10.0") + implementation("org.itsallcode.openfasttrace:openfasttrace-exporter-specobject:4.10.0") + implementation("org.itsallcode.openfasttrace:openfasttrace:4.10.0") } diff --git a/build-logic/src/main/kotlin/org.jabref.gradle.feature.requirementtracing.gradle.kts b/build-logic/src/main/kotlin/org.jabref.gradle.feature.requirementtracing.gradle.kts index 67446ee01a9c..67e61d4704ad 100644 --- a/build-logic/src/main/kotlin/org.jabref.gradle.feature.requirementtracing.gradle.kts +++ b/build-logic/src/main/kotlin/org.jabref.gradle.feature.requirementtracing.gradle.kts @@ -1,107 +1,3 @@ -import java.io.File -import java.util.Locale - -import org.gradle.api.Project -import org.gradle.api.plugins.ExtensionAware -import org.gradle.kotlin.dsl.getByType -import org.gradle.kotlin.dsl.register -import org.itsallcode.openfasttrace.api.core.ItemStatus -import org.itsallcode.openfasttrace.gradle.config.TagPathConfiguration -import org.itsallcode.openfasttrace.gradle.config.TracingConfig -import org.itsallcode.openfasttrace.gradle.task.CollectTask -import org.itsallcode.openfasttrace.gradle.task.TraceTask -import org.itsallcode.openfasttrace.gradle.task.config.SerializableTagPathConfig - -val taskGroupName = "trace" -val requirementConfigName = "oftRequirementConfig" - -allprojects { - val tracingConfig = extensions.create("requirementTracing", TracingConfig::class.java, project) - (tracingConfig as ExtensionAware).extensions.create("tags", TagPathConfiguration::class.java, project) -} - -gradle.projectsEvaluated { - val collectTask = tasks.register("collectRequirements") { - group = taskGroupName - description = "Collect requirements and generate specobject file" - inputDirectories.set(getAllInputDirectories(rootProject.allprojects)) - outputFile.set(rootProject.layout.buildDirectory.file("reports/requirements.xml")) - pathConfig.set(getPathConfig(rootProject.allprojects)) - } - - tasks.register("traceRequirements") { - group = taskGroupName - description = "Trace requirements and generate tracing report" - dependsOn(collectTask) - - val tracingConfig = rootProject.getTracingConfig() - failBuild.set(tracingConfig.failBuild) - requirementsFile.set(collectTask.flatMap { task -> task.outputFile }) - - if (tracingConfig.reportFile.isPresent) { - outputFile.set(tracingConfig.reportFile) - } else { - val extension = if (tracingConfig.reportFormat.get() == "html") "html" else "txt" - outputFile.set(rootProject.layout.buildDirectory.file("reports/tracing.$extension")) - } - - reportVerbosity.set(tracingConfig.reportVerbosity) - reportFormat.set(tracingConfig.reportFormat) - importedRequirements.set(getImportedRequirements(rootProject.allprojects)) - filteredArtifactTypes.set(tracingConfig.filteredArtifactTypes) - filteredTags.set(tracingConfig.filteredTags) - filterAcceptsItemsWithoutTag.set(tracingConfig.filterAcceptsItemsWithoutTag) - filterWantedStatuses.set(getWantedStatuses(tracingConfig)) - detailsSectionDisplay.set(tracingConfig.detailsSectionDisplay) - } -} - -fun Project.getTracingConfig(): TracingConfig = extensions.getByType() - -fun getAllInputDirectories(allProjects: Set): Set = allProjects - .flatMap { project -> project.getTracingConfig().inputDirectories.files } - .toSet() - -fun getImportedRequirements(allProjects: Set): Set = allProjects - .flatMap(::getImportedRequirements) - .toSet() - -fun getImportedRequirements(project: Project): Set { - val importedRequirements = project.getTracingConfig().importedRequirements.orNull.orEmpty() - if (importedRequirements.isEmpty()) { - return emptySet() - } - - val configuration = project.configurations.findByName(requirementConfigName) - ?: project.configurations.create(requirementConfigName) - - importedRequirements.forEach { dependency -> - project.dependencies.add(requirementConfigName, dependency) - } - - return configuration.files -} - -fun getPathConfig(allProjects: Set): List = allProjects - .mapNotNull(::getTagPathConfig) - -fun getTagPathConfig(project: Project): SerializableTagPathConfig? { - val tagPathConfig = project.getTracingConfig().tagPathConfig - if (tagPathConfig.pathConfig.isEmpty()) { - return null - } - return SerializableTagPathConfig(tagPathConfig) -} - -fun getWantedStatuses(tracingConfig: TracingConfig): Set = - tracingConfig.filterWantedStatuses.getOrElse(emptySet()).map(::convertStatus).toSet() - -fun convertStatus(value: String): ItemStatus = try { - ItemStatus.valueOf(value.uppercase(Locale.ROOT)) -} catch (exception: IllegalArgumentException) { - val validStatuses = ItemStatus.values().joinToString(", ") { status -> status.name } - throw IllegalArgumentException( - "Invalid status '$value'. Valid statuses are: $validStatuses", - exception, - ) +plugins { + id("org.itsallcode.openfasttrace") } diff --git a/docs/code-howtos/requirements.md b/docs/code-howtos/requirements.md new file mode 100644 index 000000000000..51cd70ac647a --- /dev/null +++ b/docs/code-howtos/requirements.md @@ -0,0 +1,218 @@ +--- +parent: Code Howtos +--- +# Requirements + +In JabRef, we use [OpenFastTrace](https://github.com/itsallcode/openfasttrace) to track requirements. This is a handy tool that allows us to express our ideas and link them to the code. It supports both backward and forward tracing. For example, questions such as "How is the requirement implemented?" (forward trace) or "Which requirement led to this implementation?" (backward trace) are easy to answer. + +## Example + +Imagine you are developing Git features in JabRef and want to ensure that a PAT (Personal Access Token) is verified and has sufficient access rights. This is a very useful feature. With OFT, you can write a human-readable requirement in Markdown like this: + +```markdown +## GitHub personal access token verification +`req~git.share.personal-access-token-verification~1` + +The GitHub sharing dialog must allow users to verify that their personal access token has push access to the configured GitHub repository before sharing a library. + +Needs: impl +``` + +The power of OFT is that it actually **tracks** requirements and can report which requirements were not implemented. + +To link the requirement to the code, you write this in the code: + +```java + public void checkGitHubAccess() { + // [impl->req~git.share.personal-access-token-verification~1] + BackgroundTask + .wrap(() -> gitHubRepositoryAccessChecker.check(repositoryUrlProperty.get().trim(), gitPreferences.getUsername(), gitPreferences.getPat())) + .onSuccess(this::showGitHubAccessResult) + .onFailure(e -> { + LOGGER.debug("Could not check GitHub repository access", e); + dialogService.showErrorDialogAndWait( + Localization.lang("GitHub access"), + Localization.lang("Could not connect to GitHub. Please check your network connection and try again.")); + }) + .executeWith(taskExecutor); + } +``` + +After that, running `traceRequirements` will succeed. + +Tracing requirements is useful because it helps us: + +- link ideas and human-oriented descriptions to the code; +- keep implementation and tests (and other artifacts) aligned; +- track requirement revisions so that updates in Markdown also require updates to the relevant code; +- add context about why the code was written or why a requirement is needed, including links to issues; +- provide a useful aid for AI agents; +- prevent people from forgetting or deleting important requirements or code as the project evolves. + +## How to use OFT + +### Tooling + +OFT is available on GitHub at , where you can use the CLI. + +It is highly recommended to use the LSP server and related extensions (not official and not affiliated with OFT): (with VS Code and IntelliJ integrations). + +We also integrate it with Gradle. You can run the `traceRequirements` task, which produces `build/reports/tracing.txt` with a detailed report. + +We also have automated CI checks to verify requirement coverage; uncovered requirements are treated as an error. + +We recommend using VS Code with the `markdownlint` extension to edit requirement files rather than IntelliJ, because VS Code understands `markdownlintdisable` directives. + +### Requirement syntax + +Requirements should be written in the main JabRef repository under `docs/requirements`. They are grouped by the feature they relate to. Universal requirements can go in cross-cutting categories such as UX. + +You write a requirement identifier directly below a Markdown heading. + +Example: + +```markdown +### Example +`req~ai.example~1` +``` + +The requirement ID (`req~ai.example~1`) consists of several parts separated by a tilde: + +- an artifact type: `req`; +- the main part: `ai.example`; +- the revision: `1`. + +It is important that there is no empty line directly after the heading. + +{: note} +You need to add `` at the end of the file because the ID of the requirement must follow the heading directly. + +After adding a heading and identifier, you write the description of the requirement. At the end of the requirement, you list the artifact types it needs. + +```markdown +Needs: impl, utest +``` + +## Linking implementations + +After writing the requirement, you add a comment at the implementation site to indicate that it is covered: + +Java code: + +```java +// [impl->req~ai.example~1] +``` + +Markdown: + +```markdown + +``` + +## Conventions used in JabRef + +For requirement IDs, we follow the OFT standard artifact types, with the addition of `adr`. For the main part, we separate the path with `.`, and separate words with a hyphen, as in the example above. + +We use these artifact types: + +- `feat`: general features or ideas, primarily user-facing, unrefined requirements of varying size; +- `req`: a specific nuance, cross-cutting requirement, or bug fix; +- `impl`: a code implementation (typically Java, but can also be GitHub CI/CD code, etc.); +- `utest`: a unit test; +- `itest`: a test involving external services; +- `dsn`: a design document for specifications; +- `arch`: a high-level design requirement; +- `uman`: a user manual page or section; +- `adr`: an Architectural Decision Record. + +Throughout development, you will mainly work with `feat`, `req`, `impl`, and `utest`. Please try to use these four types, as the others are rarely needed and we do not have good examples for them. + +We would really like to use the `uman` artifact type, as new features often also need to be explained to users. At the time of writing, we do not use this type in the requirements because we do not have a cross-repository setup for OFT. + +The boundary between `feat` and `req` can sometimes be debatable, because from a software engineering perspective they refer to the same thing. However, we assign them based on the outcome: a new fetcher is a feature, while special handling of a field is a requirement. This gives us an overview of what we have in JabRef. + +## How to write a requirement + +### Requirement style + +**General ideas**: + +- `req~` = a constraint the system **must** satisfy. `feat~` = a capability offered to the user. +- Title grammar should match the tag: `req~` → **"Subject must verb"**, `feat~` → **"User can verb"**. +- One item, one requirement. If the title needs "and," split it into two items. +- The title should carry the full normative statement; the description should only add what the title could not fit. + +**Title style**: + +- Put the subject first, then the modal verb. +- For `req~`, always use **must** — never "should," "needs to," or "is required to." Use one modal verb only. +- Avoid nominalizations ("must be verified," not "verification"). +- Avoid system-as-narrator phrasing ("allows the user to," "offers to") — state what must happen. + +**Description style**: + +Use it only for what the title cannot carry: + +- the triggering condition; +- edge cases or boundary behavior; +- a brief rationale if needed; +- the GitHub issue; +- other relevant context. + +Do not repeat the subject + verb from the title, do not smuggle in a second requirement, and do not write marketing copy. + +### Example of a requirement + +**Bad**: + +```markdown +## GitHub personal access token verification +`req~git.share.personal-access-token-verification~1` + +The GitHub sharing dialog must allow users to verify that their personal +access token has push access to the configured GitHub repository before +sharing a library. +``` + +There is no modal verb in the title, and the subject-first structure is missing. The normative content is buried in the paragraph instead of the title. + +**Good**: + +```markdown +## GitHub personal access token push access must be verifiable before sharing +`req~git.share.personal-access-token-verification~1` + +Verification happens in the GitHub sharing dialog before the library is shared. +``` + +The title carries the full constraint, and the description adds only the missing detail about where it happens. + +### Linking requirements + +The syntax for linking a requirement to code was already shown above. However, you should still follow the principle of **linking the requirement to the most specific code location**. Only if there is no specific place, or if too many components are involved, can you link it at the method or class level. + +In extreme cases, you can write the linking comment and add a second comment below it explaining how the implementation works. + +All comments should be placed before the annotations. + +### Unimplemented requirements + +Sometimes you have a lot of useful ideas but no time to write or implement them. In that case, you can write the requirements and mark them as `draft` by adding: + +```markdown +Status: draft +``` + +This way they will be filtered out when running `traceRequirements`, while still preserving the ideas. + +## More information + +We use OFT mainly to trace small ideas, notes, and wishes rather than to manage the full software engineering requirements process. The concepts and terminology of software requirements engineering are still useful for structuring and writing requirements, and they help us express them clearly. However, we do not apply all of its principles in full. + +For example, INVEST is useful for writing better requirement descriptions, but we do not treat our requirements as fully negotiable items because they are more like concrete instructions or orders than open-ended, negotiable agreements. We also do not explicitly record a separate value dimension for each requirement in this workflow. + +It is better to write more small requirements than one large one. This makes coverage and tracking more detailed. + +- [General reading on traceability](https://www.sodiuswillert.com/en/blog/implementing-requirements-traceability-in-systems-software-engineering) +- [OFT User guide](https://github.com/itsallcode/openfasttrace/blob/main/doc/user_guide/user_guide.md) +- [OFT artifacts](https://github.com/itsallcode/openfasttrace/blob/main/doc/user_guide/user_guide.md#specification-item-artifact-type) diff --git a/docs/requirements/index.md b/docs/requirements/index.md index 4b47c5cb8877..aa430f115d6e 100644 --- a/docs/requirements/index.md +++ b/docs/requirements/index.md @@ -4,70 +4,7 @@ has_children: true --- # Requirements -Requirements capture what the JabRef should do. -Regard it as structured representation of implemented issues. +Requirements capture what JabRef should do. +Think of them as a structured representation of implemented issues. -JabRef uses [OpenFastTrace](https://github.com/itsallcode/openfasttrace) to identify each requirement and to link implementation, tests, and more to it. -This enables forward and backward tracing. -For instance, questions like: "How is the requirement implemented?" (forward trace) or "Which requirement lead to this implementation?" (backward trace). - -## Specifying requirements - -One writes directly below a Markdown heading a requirement identifier. - -Example: - -```markdown -### Example -`req~ai.example~1` -``` - -It is important that there is no empty line directly after the heading. - -{: note} -One needs to add `` to the end of the file, because the ID of the requirement needs to follow the heading directly. - -After putting a heading and an identifier, one writes down at the requirement. -Directly at the end, one writes that it requires an implementation: - -```markdown -Needs: impl -``` - -One can also state that there should be detailed design document (`dsn`). -However, typically in JabRef, we go from the requirement directly to the implementation. - -When writing a requirement, ensure that it is atomic. One requirement is related to 1 thing/feature at a time. - -## Linking implementations - -After writing the requirement, at the implementation, a comment is added that this implementation is covered: - -```java -// [impl->req~ai.example~1] -``` - -## Automated checks - -When executing the gradle task `traceRequirements`, `build/reports/tracing.txt` is generated. -It captures the links between the artifacts (requirement, implementation, ...) - -In case of a tracing error, one can inspect `build/tracing.txt` to see which requirements were not covered. - -## Custom Artifact Types - -- `pp`: means that this requirement should be guarded with a privacy policy banner. The feature should not do anything if the user does not accept the privacy policy. The specific privacy policy depends on the feature. -- `guard`: means that this action might be dangerous and should be guarded with a confirmation dialog. -- `adr`: an architectural decision record in `docs/decisions/` that code carries out. The identifier follows the ADR's title (`adr~hardcode-fieldnames~1`, without the file's number prefix, because an identifier must not start with a digit), and implementations link to it with `// [impl->adr~hardcode-fieldnames~1]`. -- `model`: means that this feature requires a model. - -While not a custom artifact, but we interpret these OpenFastTrace artifact types as follows: - -- `dsn`: means writing an ADR. - -## More Information - -- [General reading on traceability](https://www.sodiuswillert.com/en/blog/implementing-requirements-traceability-in-systems-software-engineering) -- [User manual of OpenFastTrace](https://openfasttrace.itsallcode.org/user_guide/user_guide.html) - -We recommend using VS Code with `markdownlint` extension to edit requirement files, and not IntelliJ, as VS Code understands `markdownlintdisable` directives. +For more information on how to write requirements and use OpenFastTrace, see the [Requirements code-howto](../code-howtos/requirements.md). diff --git a/docs/requirements/search-within-library.md b/docs/requirements/search-within-library.md index c5e2b5c2b925..8b659623c988 100644 --- a/docs/requirements/search-within-library.md +++ b/docs/requirements/search-within-library.md @@ -59,6 +59,16 @@ When linked-file full-text indexing is enabled, users must be able to search the Needs: impl, utest +## Case-sensitive full-text search +`req~jabgui.search.fulltext.case-sensitive~1` + +Issue: [#13048](https://github.com/JabRef/jabref/issues/13048) + +The case-sensitive operators of the search syntax (`=!`, `==!`, `=~!`) must also apply to the contents and annotations of linked files. +Searching for `any ==! SEE` must not match a file that only contains `See`. + +Needs: impl, utest + ## Full-text search survives a query only Lucene rejects `req~jabgui.search.fulltext.lenient-query-parsing~1` diff --git a/docs/requirements/ux.md b/docs/requirements/ux.md index b745b12e3b25..89d84f0aef92 100644 --- a/docs/requirements/ux.md +++ b/docs/requirements/ux.md @@ -188,4 +188,12 @@ The groups walkthrough must open its bundled example library, display the Groups Needs: impl +## Running background tasks can be viewed at any time + +`req~ux.background-tasks.viewer~1` + +The Help menu offers a dialog listing the currently running background tasks, so they can be inspected and cancelled without quitting JabRef. + +Needs: impl + diff --git a/jabgui/src/main/java/org/jabref/gui/DialogService.java b/jabgui/src/main/java/org/jabref/gui/DialogService.java index 6407c68bf8f2..cc7793d992e2 100644 --- a/jabgui/src/main/java/org/jabref/gui/DialogService.java +++ b/jabgui/src/main/java/org/jabref/gui/DialogService.java @@ -213,7 +213,11 @@ Optional showCustomButtonDialogWithTooltipsAndWait(Alert.AlertType t /// @param title title of the dialog /// @param content message to show below the list of background tasks /// @param stateManager The [StateManager] which contains the background tasks - Optional showBackgroundProgressDialogAndWait(String title, String content, StateManager stateManager); + /// @return true if the user chose to quit or all tasks finished + boolean showBackgroundProgressDialogAndWait(String title, String content, StateManager stateManager); + + /// Shows a non-modal dialog listing the running background tasks. + void showBackgroundTasksDialog(StateManager stateManager); /// Shows a new file save dialog. The method doesn't return until the /// displayed file save dialog is dismissed. The return value specifies the diff --git a/jabgui/src/main/java/org/jabref/gui/JabRefDialogService.java b/jabgui/src/main/java/org/jabref/gui/JabRefDialogService.java index 3eb222155de8..499d633ca16b 100644 --- a/jabgui/src/main/java/org/jabref/gui/JabRefDialogService.java +++ b/jabgui/src/main/java/org/jabref/gui/JabRefDialogService.java @@ -12,6 +12,7 @@ import java.util.function.Consumer; import javafx.application.Platform; +import javafx.beans.value.ChangeListener; import javafx.collections.ObservableList; import javafx.concurrent.Task; import javafx.print.PrinterJob; @@ -25,6 +26,7 @@ import javafx.scene.control.ChoiceDialog; import javafx.scene.control.ComboBox; import javafx.scene.control.Dialog; +import javafx.scene.control.DialogEvent; import javafx.scene.control.DialogPane; import javafx.scene.control.Label; import javafx.scene.control.TextArea; @@ -59,6 +61,8 @@ import com.dlsc.gemsfx.infocenter.Notification; import com.dlsc.gemsfx.infocenter.NotificationGroup; import com.tobiasdiez.easybind.EasyBind; +import com.tobiasdiez.easybind.EasyBinding; +import com.tobiasdiez.easybind.Subscription; import org.controlsfx.control.TaskProgressView; import org.controlsfx.control.textfield.CustomPasswordField; import org.controlsfx.dialog.ExceptionDialog; @@ -429,34 +433,54 @@ public void showProgressDialogAndWait(String title, String content, Task } @Override - public Optional showBackgroundProgressDialogAndWait(String title, String content, StateManager stateManager) { - TaskProgressView> taskProgressView = new TaskProgressView<>(); - EasyBind.bindContent(taskProgressView.getTasks(), stateManager.getRunningBackgroundTasks()); - taskProgressView.setRetainTasks(false); - taskProgressView.setGraphicFactory(task -> ThemeManager.downloadIconTitleMap.getOrDefault(task.getTitle(), null)); - - Label message = new Label(content); - - VBox box = new VBox(taskProgressView, message); - + public boolean showBackgroundProgressDialogAndWait(String title, String content, StateManager stateManager) { DialogPane contentPane = new DialogPane(); - contentPane.setContent(box); + + ButtonType quit = new ButtonType(Localization.lang("Quit anyway"), ButtonBar.ButtonData.OK_DONE); + ButtonType keepOpen = new ButtonType(Localization.lang("Keep JabRef open"), ButtonBar.ButtonData.CANCEL_CLOSE); FXDialog alert = new FXDialog(AlertType.WARNING, title); + contentPane.setContent(new VBox(createBackgroundTasksView(stateManager, alert), new Label(content))); alert.setDialogPane(contentPane); - alert.getButtonTypes().setAll(ButtonType.YES, ButtonType.CANCEL); + alert.getButtonTypes().setAll(quit, keepOpen); alert.getDialogPane().setMinHeight(Region.USE_PREF_SIZE); alert.setResizable(true); alert.initOwner(mainWindow); - stateManager.getAnyTasksThatWillNotBeRecoveredRunning().addListener((_, _, newValue) -> { + EasyBinding tasksRunning = stateManager.getAnyTasksThatWillNotBeRecoveredRunning(); + ChangeListener closeWhenDone = (_, _, newValue) -> { if (!newValue) { - alert.setResult(ButtonType.YES); + alert.setResult(quit); alert.close(); } - }); + }; + tasksRunning.addListener(closeWhenDone); + alert.setOnHidden(_ -> tasksRunning.removeListener(closeWhenDone)); - return alert.showAndWait(); + return alert.showAndWait().filter(quit::equals).isPresent(); + } + + @Override + public void showBackgroundTasksDialog(StateManager stateManager) { + DialogPane contentPane = new DialogPane(); + + FXDialog dialog = new FXDialog(AlertType.INFORMATION, Localization.lang("Background tasks"), false); + contentPane.setContent(createBackgroundTasksView(stateManager, dialog)); + dialog.setDialogPane(contentPane); + dialog.getButtonTypes().setAll(ButtonType.CLOSE); + dialog.setResizable(true); + dialog.initOwner(mainWindow); + dialog.show(); + } + + /// The binding is released when `dialog` is hidden: the task list outlives every dialog showing it. + private static TaskProgressView> createBackgroundTasksView(StateManager stateManager, Dialog dialog) { + TaskProgressView> taskProgressView = new TaskProgressView<>(); + Subscription tasks = EasyBind.bindContent(taskProgressView.getTasks(), stateManager.getRunningBackgroundTasks()); + dialog.addEventHandler(DialogEvent.DIALOG_HIDDEN, _ -> tasks.unsubscribe()); + taskProgressView.setRetainTasks(false); + taskProgressView.setGraphicFactory(task -> ThemeManager.downloadIconTitleMap.getOrDefault(task.getTitle(), null)); + return taskProgressView; } @Override diff --git a/jabgui/src/main/java/org/jabref/gui/actions/StandardActions.java b/jabgui/src/main/java/org/jabref/gui/actions/StandardActions.java index 555d4ec6ab27..39fa9af28a3f 100644 --- a/jabgui/src/main/java/org/jabref/gui/actions/StandardActions.java +++ b/jabgui/src/main/java/org/jabref/gui/actions/StandardActions.java @@ -203,6 +203,7 @@ public enum StandardActions implements Action { OPEN_GITHUB("GitHub", Localization.lang("Opens JabRef's GitHub page"), IconTheme.JabRefIcons.GITHUB), DONATE(Localization.lang("Donate to JabRef"), Localization.lang("Donate to JabRef"), IconTheme.JabRefIcons.DONATE), OPEN_FORUM(Localization.lang("Community forum"), Localization.lang("Community forum"), IconTheme.JabRefIcons.FORUM), + SHOW_BACKGROUND_TASKS(Localization.lang("View background tasks"), Localization.lang("Show running background tasks")), ERROR_CONSOLE(Localization.lang("View event log"), Localization.lang("Display all error messages")), SEARCH_FOR_UPDATES(Localization.lang("Check for updates")), ABOUT(Localization.lang("About JabRef"), Localization.lang("About JabRef")), diff --git a/jabgui/src/main/java/org/jabref/gui/fieldeditors/LinkedFilesEditor.java b/jabgui/src/main/java/org/jabref/gui/fieldeditors/LinkedFilesEditor.java index f3e7f3ba1fef..cc59b9fc8208 100644 --- a/jabgui/src/main/java/org/jabref/gui/fieldeditors/LinkedFilesEditor.java +++ b/jabgui/src/main/java/org/jabref/gui/fieldeditors/LinkedFilesEditor.java @@ -1,5 +1,6 @@ package org.jabref.gui.fieldeditors; +import java.util.List; import java.util.Optional; import javafx.beans.binding.Bindings; @@ -420,6 +421,15 @@ public Parent getNode() { @FXML public void addNewFile() { + // A selected suggestion (file found in the file directory, not yet linked) is what the user wants to add, + // so "+" links it directly instead of asking for a new file link. + List selectedSuggestions = listView.getSelectionModel().getSelectedItems().stream() + .filter(LinkedFileViewModel::isAutomaticallyFound) + .toList(); + if (!selectedSuggestions.isEmpty()) { + selectedSuggestions.forEach(LinkedFileViewModel::acceptAsLinked); + return; + } dialogService.showCustomDialogAndWait(new LinkedFileEditDialog()).filter(file -> !file.isEmpty()).ifPresent(newLinkedFile -> viewModel.addNewLinkedFile(newLinkedFile)); } diff --git a/jabgui/src/main/java/org/jabref/gui/frame/JabRefFrameViewModel.java b/jabgui/src/main/java/org/jabref/gui/frame/JabRefFrameViewModel.java index 2fac15abc6f3..ec35a4248a64 100644 --- a/jabgui/src/main/java/org/jabref/gui/frame/JabRefFrameViewModel.java +++ b/jabgui/src/main/java/org/jabref/gui/frame/JabRefFrameViewModel.java @@ -20,7 +20,6 @@ import javafx.beans.value.ChangeListener; import javafx.beans.value.ObservableBooleanValue; -import javafx.scene.control.ButtonType; import org.jabref.cli.CliImportHelper; import org.jabref.gui.DialogService; @@ -137,11 +136,11 @@ public boolean close() { // Ask if the user really wants to close, if there are still background tasks running // The background tasks may make changes themselves that need saving. if (stateManager.getAnyTasksThatWillNotBeRecoveredRunning().getValue()) { - Optional shouldClose = dialogService.showBackgroundProgressDialogAndWait( + boolean shouldClose = dialogService.showBackgroundProgressDialogAndWait( Localization.lang("Please wait..."), Localization.lang("Waiting for background tasks to finish. Quit anyway?"), stateManager); - if (!(shouldClose.isPresent() && (shouldClose.get() == ButtonType.YES))) { + if (!shouldClose) { return false; } } diff --git a/jabgui/src/main/java/org/jabref/gui/frame/MainMenu.java b/jabgui/src/main/java/org/jabref/gui/frame/MainMenu.java index 9da5a1be4def..29aaa7276651 100644 --- a/jabgui/src/main/java/org/jabref/gui/frame/MainMenu.java +++ b/jabgui/src/main/java/org/jabref/gui/frame/MainMenu.java @@ -376,6 +376,13 @@ private void createMenu() { new SeparatorMenuItem(), + // [impl->req~ux.background-tasks.viewer~1] + factory.createMenuItem(StandardActions.SHOW_BACKGROUND_TASKS, new SimpleCommand() { + @Override + public void execute() { + dialogService.showBackgroundTasksDialog(stateManager); + } + }), factory.createMenuItem(StandardActions.ERROR_CONSOLE, new ErrorConsoleAction()), new SeparatorMenuItem(), diff --git a/jabgui/src/main/java/org/jabref/migrations/PreferencesMigrations.java b/jabgui/src/main/java/org/jabref/migrations/PreferencesMigrations.java index d15e8063216e..11a6c61425b2 100644 --- a/jabgui/src/main/java/org/jabref/migrations/PreferencesMigrations.java +++ b/jabgui/src/main/java/org/jabref/migrations/PreferencesMigrations.java @@ -27,6 +27,7 @@ import org.jabref.logic.cleanup.CleanupPreferences; import org.jabref.logic.cleanup.FieldFormatterCleanupActions; import org.jabref.logic.os.OS; +import org.jabref.logic.preferences.GrobidUrlMigration; import org.jabref.logic.preferences.JabRefCliPreferences; import org.jabref.logic.preview.TextBasedPreviewLayout; import org.jabref.logic.shared.security.Password; @@ -101,6 +102,7 @@ public static void runMigrations(JabRefGuiPreferences preferences) { upgradeTheme(preferences); migrateFileAnnotationsTabVisibility(preferences); upgradeEntryEditorCustomTabs(preferences); + GrobidUrlMigration.migrate(preferences); } /// Up to and including v6.0-alpha.6, custom entry editor tabs were stored in two parallel numbered diff --git a/jabgui/src/test/java/org/jabref/gui/frame/JabRefFrameViewModelTest.java b/jabgui/src/test/java/org/jabref/gui/frame/JabRefFrameViewModelTest.java index 6ca04c7ea9e5..322c6a076e7e 100644 --- a/jabgui/src/test/java/org/jabref/gui/frame/JabRefFrameViewModelTest.java +++ b/jabgui/src/test/java/org/jabref/gui/frame/JabRefFrameViewModelTest.java @@ -22,13 +22,17 @@ import org.jabref.model.util.FileUpdateMonitor; import com.airhacks.afterburner.injection.Injector; +import com.tobiasdiez.easybind.EasyBind; +import com.tobiasdiez.easybind.EasyBinding; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.Answers; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -68,6 +72,7 @@ void setUp() { when(stateManager.getOpenDatabases()).thenReturn(FXCollections.observableArrayList()); when(stateManager.getUndoManager(any())).thenReturn(undoManager); when(stateManager.getActiveDatabase()).thenReturn(Optional.empty()); + when(stateManager.getAnyTasksThatWillNotBeRecoveredRunning()).thenReturn(tasksRunning(false)); viewModel = new JabRefFrameViewModel( preferences, @@ -83,6 +88,11 @@ void setUp() { ); } + /// [StateManager#getAnyTasksThatWillNotBeRecoveredRunning] returns an [EasyBinding], which cannot be created from a plain property. + private static EasyBinding tasksRunning(boolean running) { + return EasyBind.reduce(FXCollections.observableArrayList(running), tasks -> tasks.anyMatch(task -> task)); + } + @Test void addParserResultCreatesNewTabIfNoneOpen() { // Given @@ -126,4 +136,14 @@ void addParserResultUsesExistingTabIfOpen() { // Then verify(dialogService).showCustomDialogAndWait(any()); } + + @Test + void closeKeepsJabRefOpenWhenUserDeclinesWhileTasksRun() { + when(stateManager.getAnyTasksThatWillNotBeRecoveredRunning()).thenReturn(tasksRunning(true)); + when(dialogService.showBackgroundProgressDialogAndWait(any(), any(), eq(stateManager))).thenReturn(false); + + assertFalse(viewModel.close()); + + verify(tabContainer, never()).closeTabs(any(), eq(false)); + } } diff --git a/jabkit/src/main/java/org/jabref/toolkit/JabKitLauncher.java b/jabkit/src/main/java/org/jabref/toolkit/JabKitLauncher.java index 9be6abbb68ee..749d632ab453 100644 --- a/jabkit/src/main/java/org/jabref/toolkit/JabKitLauncher.java +++ b/jabkit/src/main/java/org/jabref/toolkit/JabKitLauncher.java @@ -25,6 +25,7 @@ import org.jabref.logic.net.ssl.SSLPreferences; import org.jabref.logic.net.ssl.TrustStoreManager; import org.jabref.logic.preferences.CliPreferences; +import org.jabref.logic.preferences.GrobidUrlMigration; import org.jabref.logic.preferences.JabRefCliPreferences; import org.jabref.logic.protectedterms.ProtectedTermsLoader; import org.jabref.logic.util.BuildInfo; @@ -54,7 +55,7 @@ /// It does not open any GUI. /// For the GUI application see [org.jabref.Launcher]. /// -/// Does not do any preference migrations. +/// Does not run GUI-specific preference migrations. public class JabKitLauncher { // J.U.L. bridge to SLF4J must be initialized before any logger is created, see initLogging() private static Logger LOGGER; @@ -70,6 +71,7 @@ public static void main(String[] args) { try { final JabRefCliPreferences preferences = JabRefCliPreferences.getInstance(); + GrobidUrlMigration.migrate(preferences); Injector.setModelOrService(CliPreferences.class, preferences); BuildInfo buildInfo = new BuildInfo(); diff --git a/jablib/src/main/java/module-info.java b/jablib/src/main/java/module-info.java index f52c31f7ae3d..b5b0d0e2c924 100644 --- a/jablib/src/main/java/module-info.java +++ b/jablib/src/main/java/module-info.java @@ -292,7 +292,7 @@ uses org.apache.lucene.codecs.lucene104.Lucene104Codec; requires org.apache.lucene.analysis.common; requires transitive org.apache.lucene.core; - requires transitive org.apache.lucene.highlighter; + requires org.apache.lucene.highlighter; requires org.apache.lucene.queryparser; // endregion diff --git a/jablib/src/main/java/org/jabref/logic/importer/util/GrobidPreferences.java b/jablib/src/main/java/org/jabref/logic/importer/util/GrobidPreferences.java index 4386cbc837c6..a9d736e9f0ee 100644 --- a/jablib/src/main/java/org/jabref/logic/importer/util/GrobidPreferences.java +++ b/jablib/src/main/java/org/jabref/logic/importer/util/GrobidPreferences.java @@ -15,7 +15,7 @@ private GrobidPreferences() { this( false, // Grobid enabled false, // Grobid use asked - "http://grobid.jabref.org:8070" // Grobid URL + "https://grobid.jabref.org" // Grobid URL ); } diff --git a/jablib/src/main/java/org/jabref/logic/importer/util/GrobidService.java b/jablib/src/main/java/org/jabref/logic/importer/util/GrobidService.java index d2d1a1918b49..f05d5cf1994a 100644 --- a/jablib/src/main/java/org/jabref/logic/importer/util/GrobidService.java +++ b/jablib/src/main/java/org/jabref/logic/importer/util/GrobidService.java @@ -5,6 +5,7 @@ import java.nio.file.Path; import java.util.ArrayList; import java.util.List; +import java.util.Locale; import java.util.Optional; import org.jabref.logic.importer.ImportFormatPreferences; @@ -20,8 +21,6 @@ /// Implements an API to a GROBID server, as described at /// https://grobid.readthedocs.io/en/latest/Grobid-service/#grobid-web-services /// -/// Note: Currently a custom GROBID server is used... -/// https://github.com/NikodemKch/grobid /// /// The methods are structured to match the GROBID server api. /// Each method corresponds to a GROBID service request. Only the ones already used are already implemented. @@ -67,7 +66,11 @@ public Optional processCitation(String rawCitation, ImportFormatPrefer String httpResponse = response.body(); LOGGER.debug("raw citation -> response: {}, {}", rawCitation, httpResponse); - if (httpResponse == null || "@misc{-1,\n author = {}\n}\n".equals(httpResponse) || httpResponse.equals("@misc{-1,\n author = {" + rawCitation + "}\n}\n")) { // This filters empty BibTeX entries + if (""" + @misc{-1, + author = {} + } + """.equals(httpResponse) || httpResponse.equals("@misc{%s,\n author = {%s}\n}\n".formatted(rawCitation.toLowerCase(Locale.ROOT), rawCitation))) { // This filters empty BibTeX entries, lowercase!! throw new IOException("The GROBID server response does not contain anything."); } diff --git a/jablib/src/main/java/org/jabref/logic/preferences/GrobidUrlMigration.java b/jablib/src/main/java/org/jabref/logic/preferences/GrobidUrlMigration.java new file mode 100644 index 000000000000..55b85ab66af3 --- /dev/null +++ b/jablib/src/main/java/org/jabref/logic/preferences/GrobidUrlMigration.java @@ -0,0 +1,19 @@ +package org.jabref.logic.preferences; + +import org.jabref.logic.importer.util.GrobidPreferences; + +import org.jspecify.annotations.NullMarked; + +@NullMarked +public final class GrobidUrlMigration { + private static final String LEGACY_GROBID_URL = "http://grobid.jabref.org:8070"; + + private GrobidUrlMigration() { + } + + public static void migrate(JabRefCliPreferences preferences) { + if (LEGACY_GROBID_URL.equals(preferences.get(JabRefCliPreferences.GROBID_URL, ""))) { + preferences.put(JabRefCliPreferences.GROBID_URL, GrobidPreferences.getDefault().getGrobidURL()); + } + } +} diff --git a/jablib/src/main/java/org/jabref/logic/search/query/SearchToLuceneVisitor.java b/jablib/src/main/java/org/jabref/logic/search/query/SearchToLuceneVisitor.java index beaefbd17daf..47749e17ed10 100644 --- a/jablib/src/main/java/org/jabref/logic/search/query/SearchToLuceneVisitor.java +++ b/jablib/src/main/java/org/jabref/logic/search/query/SearchToLuceneVisitor.java @@ -3,6 +3,7 @@ import java.util.EnumSet; import java.util.List; import java.util.Locale; +import java.util.stream.Collectors; import org.jabref.model.search.LinkedFilesConstants; import org.jabref.model.search.SearchFlags; @@ -13,6 +14,7 @@ import org.apache.lucene.queryparser.classic.QueryParser; /// Tests are located in `org.jabref.logic.search.query.SearchQueryLuceneConversionTest`. +// [impl->req~jabgui.search.fulltext.case-sensitive~1] public class SearchToLuceneVisitor extends SearchBaseVisitor { private final EnumSet searchFlags; @@ -66,12 +68,10 @@ public String visitComparison(SearchParser.ComparisonContext ctx) { String term = SearchQuery.unescapeSearchValue(ctx.searchValue()); boolean isQuoted = ctx.searchValue().getStart().getType() == SearchParser.STRING_LITERAL; - // unfielded expression + // unfielded expression - the search bar flags apply if (ctx.FIELD() == null) { - if (searchFlags.contains(SearchFlags.REGULAR_EXPRESSION)) { - return "/" + term + "/"; - } - return isQuoted ? "\"" + escapeQuotes(term) + "\"" : escapeForLucene(term); + String value = buildValue(term, searchFlags.contains(SearchFlags.REGULAR_EXPRESSION), isQuoted); + return searchFlags.contains(SearchFlags.CASE_SENSITIVE) ? anyFieldExpression(value) : value; } // TODO: Here, there is no unescaping of the term (e.g., field\=thing=value does not work as expected) @@ -80,9 +80,9 @@ public String visitComparison(SearchParser.ComparisonContext ctx) { return ""; } - field = SearchFieldConstants.ANY_FIELD.equals(field) || SearchFieldConstants.ANY_FIELD_ALIAS.equals(field) ? "" : field + ":"; + boolean isAnyField = SearchFieldConstants.ANY_FIELD.equals(field) || SearchFieldConstants.ANY_FIELD_ALIAS.equals(field); int operator = ctx.operator().getStart().getType(); - return buildFieldExpression(field, term, operator, isQuoted); + return buildFieldExpression(isAnyField ? "" : field, term, operator, isQuoted); } /// A valid field is a field that is supported by the Lucene index. @@ -92,18 +92,31 @@ private boolean isValidField(String field) { return SearchFieldConstants.ANY_FIELD.equals(field) || SearchFieldConstants.ANY_FIELD_ALIAS.equals(field) || LinkedFilesConstants.PDF_FIELDS.contains(field); } + /// @param field the Lucene field to search in, or the empty string to search in all of [LinkedFilesConstants#PDF_FIELDS] private String buildFieldExpression(String field, String term, int operator, boolean isQuoted) { - boolean isRegexOp = isRegexOperator(operator); - boolean isNegationOp = isNegationOperator(operator); - - if (isRegexOp) { - String expression = field + "/" + term + "/"; - return isNegationOp ? "NOT " + expression : expression; + String value = buildValue(term, isRegexOperator(operator), isQuoted); + String expression; + if (isCaseSensitiveOperator(operator)) { + expression = field.isEmpty() ? anyFieldExpression(value) : LinkedFilesConstants.caseSensitiveFieldOf(field) + ":" + value; } else { - term = isQuoted ? "\"" + escapeQuotes(term) + "\"" : escapeForLucene(term); - String expression = field + term; - return isNegationOp ? "NOT " + expression : expression; + expression = field.isEmpty() ? value : field + ":" + value; } + return isNegationOperator(operator) ? "NOT " + expression : expression; + } + + private static String buildValue(String term, boolean isRegex, boolean isQuoted) { + if (isRegex) { + return "/" + term + "/"; + } + return isQuoted ? "\"" + escapeQuotes(term) + "\"" : escapeForLucene(term); + } + + /// The default fields of the query parser are analyzed case-insensitively, so a case-sensitive search + /// across all fields has to name the case-preserving fields explicitly. + private static String anyFieldExpression(String value) { + return LinkedFilesConstants.CASE_SENSITIVE_PDF_FIELDS.stream() + .map(field -> field + ":" + value) + .collect(Collectors.joining(" OR ", "(", ")")); } private static String escapeQuotes(String term) { @@ -130,6 +143,20 @@ private static boolean isNegationOperator(int operator) { }; } + private static boolean isCaseSensitiveOperator(int operator) { + return switch (operator) { + case SearchParser.CEQUAL, + SearchParser.CEEQUAL, + SearchParser.CREEQUAL, + SearchParser.NCEQUAL, + SearchParser.NCEEQUAL, + SearchParser.NCREEQUAL -> + true; + default -> + false; + }; + } + private static boolean isRegexOperator(int operator) { return switch (operator) { case SearchParser.REQUAL, diff --git a/jablib/src/main/java/org/jabref/logic/search/sqlbased/indexing/DocumentReader.java b/jablib/src/main/java/org/jabref/logic/search/sqlbased/indexing/DocumentReader.java index e6d8ef56e5bc..b5238749997b 100644 --- a/jablib/src/main/java/org/jabref/logic/search/sqlbased/indexing/DocumentReader.java +++ b/jablib/src/main/java/org/jabref/logic/search/sqlbased/indexing/DocumentReader.java @@ -25,7 +25,9 @@ import org.slf4j.MDC; import static org.jabref.model.search.LinkedFilesConstants.ANNOTATIONS; +import static org.jabref.model.search.LinkedFilesConstants.ANNOTATIONS_CASE_SENSITIVE; import static org.jabref.model.search.LinkedFilesConstants.CONTENT; +import static org.jabref.model.search.LinkedFilesConstants.CONTENT_CASE_SENSITIVE; import static org.jabref.model.search.LinkedFilesConstants.MODIFIED; import static org.jabref.model.search.LinkedFilesConstants.PAGE_NUMBER; import static org.jabref.model.search.LinkedFilesConstants.PATH; @@ -101,7 +103,10 @@ private void addContentIfNotEmpty(PDDocument pdfDocument, Document newDocument, try { String pdfContent = pdfTextStripper.getText(pdfDocument); if (StringUtil.isNotBlank(pdfContent)) { - newDocument.add(new TextField(CONTENT.toString(), mergeLines(pdfContent), Field.Store.YES)); + String mergedContent = mergeLines(pdfContent); + newDocument.add(new TextField(CONTENT.toString(), mergedContent, Field.Store.YES)); + // Only indexed, not stored: the stored text is read from CONTENT. + newDocument.add(new TextField(CONTENT_CASE_SENSITIVE.toString(), mergedContent, Field.Store.NO)); } // Apache PDFTextStripper is 1-based. See {@link org.apache.pdfbox.text.PDFTextStripper.processPages} @@ -113,7 +118,9 @@ private void addContentIfNotEmpty(PDDocument pdfDocument, Document newDocument, .toList(); if (!annotations.isEmpty()) { - newDocument.add(new TextField(ANNOTATIONS.toString(), String.join("\n", annotations), Field.Store.YES)); + String joinedAnnotations = String.join("\n", annotations); + newDocument.add(new TextField(ANNOTATIONS.toString(), joinedAnnotations, Field.Store.YES)); + newDocument.add(new TextField(ANNOTATIONS_CASE_SENSITIVE.toString(), joinedAnnotations, Field.Store.NO)); } } catch (IOException e) { LOGGER.warn("Could not read page {} of {}", pageNumber, resolvedPath.toAbsolutePath(), e); diff --git a/jablib/src/main/java/org/jabref/logic/search/sqlbased/retrieval/LinkedFilesSearcher.java b/jablib/src/main/java/org/jabref/logic/search/sqlbased/retrieval/LinkedFilesSearcher.java index c5782653b021..d29d06eb8969 100644 --- a/jablib/src/main/java/org/jabref/logic/search/sqlbased/retrieval/LinkedFilesSearcher.java +++ b/jablib/src/main/java/org/jabref/logic/search/sqlbased/retrieval/LinkedFilesSearcher.java @@ -30,9 +30,6 @@ import org.apache.lucene.search.ScoreDoc; import org.apache.lucene.search.SearcherManager; import org.apache.lucene.search.TopDocs; -import org.apache.lucene.search.highlight.Highlighter; -import org.apache.lucene.search.highlight.QueryScorer; -import org.apache.lucene.search.highlight.SimpleHTMLFormatter; import org.jspecify.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -116,7 +113,6 @@ private SearchResults getSearchResults(TopDocs topDocs, StoredFields storedField long startTime = System.currentTimeMillis(); Map> linkedFilesMap = getLinkedFilesMap(); - Highlighter highlighter = new Highlighter(new SimpleHTMLFormatter("", ""), new QueryScorer(searchQuery)); for (ScoreDoc scoreDoc : topDocs.scoreDocs) { Document document = storedFields.document(scoreDoc.doc); @@ -130,7 +126,7 @@ private SearchResults getSearchResults(TopDocs topDocs, StoredFields storedField getFieldContents(document, LinkedFilesConstants.CONTENT), getFieldContents(document, LinkedFilesConstants.ANNOTATIONS), Integer.parseInt(getFieldContents(document, LinkedFilesConstants.PAGE_NUMBER)), - highlighter); + searchQuery); searchResults.addSearchResult(entriesWithFile, searchResult); } } diff --git a/jablib/src/main/java/org/jabref/model/search/LinkedFilesConstants.java b/jablib/src/main/java/org/jabref/model/search/LinkedFilesConstants.java index 00d041caaecd..1c15a6d01c51 100644 --- a/jablib/src/main/java/org/jabref/model/search/LinkedFilesConstants.java +++ b/jablib/src/main/java/org/jabref/model/search/LinkedFilesConstants.java @@ -1,9 +1,12 @@ package org.jabref.model.search; import java.util.List; +import java.util.Map; import org.apache.lucene.analysis.Analyzer; import org.apache.lucene.analysis.en.EnglishAnalyzer; +import org.apache.lucene.analysis.miscellaneous.PerFieldAnalyzerWrapper; +import org.apache.lucene.analysis.standard.StandardTokenizer; public enum LinkedFilesConstants { /// Version number for the search index. @@ -12,21 +15,48 @@ public enum LinkedFilesConstants { /// 1. Index changes require reindexing (e.g., new/removed/renamed fields, analyzer changes) /// 2. Lucene codec changes (see module-info.java Lucene section) /// Incrementing triggers reindexing. - VERSION("6"), + VERSION("7"), PATH("path"), CONTENT("content"), ANNOTATIONS("annotations"), + /// Case-preserving copy of [#CONTENT], used for case-sensitive searches (`=!`, `==!`, `=~!`). + CONTENT_CASE_SENSITIVE("contentCaseSensitive"), + /// Case-preserving copy of [#ANNOTATIONS], used for case-sensitive searches (`=!`, `==!`, `=~!`). + ANNOTATIONS_CASE_SENSITIVE("annotationsCaseSensitive"), PAGE_NUMBER("pageNumber"), MODIFIED("modified"); - public static final Analyzer LINKED_FILES_ANALYZER = new EnglishAnalyzer(); public static final List PDF_FIELDS = List.of(CONTENT.toString(), ANNOTATIONS.toString()); + + /// The case-preserving counterpart of each entry of [#PDF_FIELDS], in the same order. + public static final List CASE_SENSITIVE_PDF_FIELDS = List.of(CONTENT_CASE_SENSITIVE.toString(), ANNOTATIONS_CASE_SENSITIVE.toString()); + + /// [EnglishAnalyzer] lowercases and stems, so the case of the indexed text is lost. + /// The case-sensitive fields are therefore only tokenized - nothing else. + private static final Analyzer CASE_PRESERVING_ANALYZER = new Analyzer() { + @Override + protected TokenStreamComponents createComponents(String fieldName) { + return new TokenStreamComponents(new StandardTokenizer()); + } + }; + + public static final Analyzer LINKED_FILES_ANALYZER = new PerFieldAnalyzerWrapper( + new EnglishAnalyzer(), + Map.of(CONTENT_CASE_SENSITIVE.toString(), CASE_PRESERVING_ANALYZER, + ANNOTATIONS_CASE_SENSITIVE.toString(), CASE_PRESERVING_ANALYZER)); + private final String field; LinkedFilesConstants(String field) { this.field = field; } + /// @return the case-preserving counterpart of `field` if there is one, `field` itself otherwise + public static String caseSensitiveFieldOf(String field) { + int index = PDF_FIELDS.indexOf(field); + return index == -1 ? field : CASE_SENSITIVE_PDF_FIELDS.get(index); + } + @Override public String toString() { return field; diff --git a/jablib/src/main/java/org/jabref/model/search/query/SearchResult.java b/jablib/src/main/java/org/jabref/model/search/query/SearchResult.java index 0e2665b651c3..e7de69400e9d 100644 --- a/jablib/src/main/java/org/jabref/model/search/query/SearchResult.java +++ b/jablib/src/main/java/org/jabref/model/search/query/SearchResult.java @@ -3,12 +3,16 @@ import java.io.IOException; import java.util.Arrays; import java.util.List; +import java.util.stream.Stream; import org.jabref.model.search.LinkedFilesConstants; import org.apache.lucene.analysis.TokenStream; +import org.apache.lucene.search.Query; import org.apache.lucene.search.highlight.Highlighter; import org.apache.lucene.search.highlight.InvalidTokenOffsetsException; +import org.apache.lucene.search.highlight.QueryScorer; +import org.apache.lucene.search.highlight.SimpleHTMLFormatter; import org.apache.lucene.search.highlight.TextFragment; public final class SearchResult { @@ -18,7 +22,7 @@ public final class SearchResult { private final String pageContent; private final String annotation; private final int pageNumber; - private final Highlighter highlighter; + private final Query query; private List contentResultStringsHtml; private List annotationsResultStringsHtml; @@ -27,33 +31,33 @@ private SearchResult(boolean hasFulltextResults, String pageContent, String annotation, int pageNumber, - Highlighter highlighter) { + Query query) { this.hasFulltextResults = hasFulltextResults; this.path = path; this.pageContent = pageContent; this.annotation = annotation; this.pageNumber = pageNumber; - this.highlighter = highlighter; + this.query = query; } public SearchResult() { this(false, "", "", "", -1, null); } - public SearchResult(String path, String pageContent, String annotation, int pageNumber, Highlighter highlighter) { - this(true, path, pageContent, annotation, pageNumber, highlighter); + public SearchResult(String path, String pageContent, String annotation, int pageNumber, Query query) { + this(true, path, pageContent, annotation, pageNumber, query); } public List getContentResultStringsHtml() { if (contentResultStringsHtml == null) { - return contentResultStringsHtml = getHighlighterFragments(highlighter, LinkedFilesConstants.CONTENT, pageContent); + return contentResultStringsHtml = getHighlighterFragments(query, LinkedFilesConstants.CONTENT, pageContent); } return contentResultStringsHtml; } public List getAnnotationsResultStringsHtml() { if (annotationsResultStringsHtml == null) { - annotationsResultStringsHtml = getHighlighterFragments(highlighter, LinkedFilesConstants.ANNOTATIONS, annotation); + annotationsResultStringsHtml = getHighlighterFragments(query, LinkedFilesConstants.ANNOTATIONS, annotation); } return annotationsResultStringsHtml; } @@ -70,10 +74,20 @@ public int getPageNumber() { return pageNumber; } - private static List getHighlighterFragments(Highlighter highlighter, LinkedFilesConstants field, String content) { - try (TokenStream contentStream = LinkedFilesConstants.LINKED_FILES_ANALYZER.tokenStream(field.toString(), content)) { + /// Each field is highlighted only with the terms the query has for exactly that field, tokenized by that field's analyzer. + /// Otherwise, the lowercasing analyzer of `field` would also mark differently cased occurrences of a case-sensitive term. + private static List getHighlighterFragments(Query query, LinkedFilesConstants field, String content) { + return Stream.of(field.toString(), LinkedFilesConstants.caseSensitiveFieldOf(field.toString())) + .flatMap(fieldName -> getHighlighterFragments(query, fieldName, content).stream()) + .distinct() + .toList(); + } + + private static List getHighlighterFragments(Query query, String field, String content) { + Highlighter highlighter = new Highlighter(new SimpleHTMLFormatter("", ""), new QueryScorer(query, field)); + try (TokenStream contentStream = LinkedFilesConstants.LINKED_FILES_ANALYZER.tokenStream(field, content)) { TextFragment[] frags = highlighter.getBestTextFragments(contentStream, content, true, 10); - return Arrays.stream(frags).map(TextFragment::toString).toList(); + return Arrays.stream(frags).filter(frag -> frag.getScore() > 0).map(TextFragment::toString).toList(); } catch (IOException | InvalidTokenOffsetsException _) { return List.of(); } diff --git a/jablib/src/main/resources/l10n/JabRef_ar.properties b/jablib/src/main/resources/l10n/JabRef_ar.properties index 2da91418b2b6..3a9ed4705aa9 100644 --- a/jablib/src/main/resources/l10n/JabRef_ar.properties +++ b/jablib/src/main/resources/l10n/JabRef_ar.properties @@ -776,6 +776,8 @@ Unable\ to\ clear\ preferences.=تعذر إزالة الإعدادات. # Pseudonymizing via CLI +# jabkit git merge-driver + # Quick Settings # Quick Settings Buttons diff --git a/jablib/src/main/resources/l10n/JabRef_da.properties b/jablib/src/main/resources/l10n/JabRef_da.properties index b251a12faa6e..670c5c17a17c 100644 --- a/jablib/src/main/resources/l10n/JabRef_da.properties +++ b/jablib/src/main/resources/l10n/JabRef_da.properties @@ -1022,6 +1022,8 @@ Import\ preferences=Importer indstillinger # Pseudonymizing via CLI +# jabkit git merge-driver + # Quick Settings # Quick Settings Buttons diff --git a/jablib/src/main/resources/l10n/JabRef_de.properties b/jablib/src/main/resources/l10n/JabRef_de.properties index 9a25f9dd4690..feac66d1ef87 100644 --- a/jablib/src/main/resources/l10n/JabRef_de.properties +++ b/jablib/src/main/resources/l10n/JabRef_de.properties @@ -3473,6 +3473,8 @@ Pseudonymize\ the\ current\ library\ and\ generate\ a\ .bib\ and\ .csv\ file=Pse Pseudonymizing\ library\ '%0'...=Pseudonymisiere Bibliothek '%0'... Invalid\ output\ file\ type\ provided.=Ungültiger Ausgabedateityp angegeben. +# jabkit git merge-driver + # Quick Settings Quick\ settings=Schnelleinstellungen diff --git a/jablib/src/main/resources/l10n/JabRef_el.properties b/jablib/src/main/resources/l10n/JabRef_el.properties index 0f0b25fe1b13..8f1c4569287a 100644 --- a/jablib/src/main/resources/l10n/JabRef_el.properties +++ b/jablib/src/main/resources/l10n/JabRef_el.properties @@ -1725,6 +1725,8 @@ Related\ articles=Σχετικά άρθρα # Pseudonymizing via CLI +# jabkit git merge-driver + # Quick Settings # Quick Settings Buttons diff --git a/jablib/src/main/resources/l10n/JabRef_en.properties b/jablib/src/main/resources/l10n/JabRef_en.properties index 63c933b56930..4da6f17d3107 100644 --- a/jablib/src/main/resources/l10n/JabRef_en.properties +++ b/jablib/src/main/resources/l10n/JabRef_en.properties @@ -1101,6 +1101,8 @@ includes\ subgroups=includes subgroups contains=contains Waiting\ for\ background\ tasks\ to\ finish.\ Quit\ anyway?=Waiting for background tasks to finish. Quit anyway? +Quit\ anyway=Quit anyway +Keep\ JabRef\ open=Keep JabRef open Find\ and\ remove\ duplicate\ citation\ keys=Find and remove duplicate citation keys @@ -2150,6 +2152,9 @@ Clear\ recent\ libraries=Clear recent libraries Set\ up\ general\ fields=Set up general fields View\ change\ log=View change log View\ event\ log=View event log +View\ background\ tasks=View background tasks +Background\ tasks=Background tasks +Show\ running\ background\ tasks=Show running background tasks Website=Website Cleaning\ up\ entries=Cleaning up entries diff --git a/jablib/src/main/resources/l10n/JabRef_es.properties b/jablib/src/main/resources/l10n/JabRef_es.properties index 3babfdf92e4d..b0d4cbb72c76 100644 --- a/jablib/src/main/resources/l10n/JabRef_es.properties +++ b/jablib/src/main/resources/l10n/JabRef_es.properties @@ -3040,6 +3040,8 @@ Updating\ PDF\ metadata.=Actualización de metadatos PDF. Pseudonymizing\ library\ '%0'...=Seudonimizando la biblioteca '%0'... Invalid\ output\ file\ type\ provided.=Se proporcionó un tipo de archivo de salida no válido. +# jabkit git merge-driver + # Quick Settings Quick\ settings=Configuraciones rápidas diff --git a/jablib/src/main/resources/l10n/JabRef_fa.properties b/jablib/src/main/resources/l10n/JabRef_fa.properties index 0a1e6a7679e0..efec3b1ff371 100644 --- a/jablib/src/main/resources/l10n/JabRef_fa.properties +++ b/jablib/src/main/resources/l10n/JabRef_fa.properties @@ -723,6 +723,8 @@ Auto\ complete\ enabled.=تکمیل خودکار غیرفعال شد. # Pseudonymizing via CLI +# jabkit git merge-driver + # Quick Settings # Quick Settings Buttons diff --git a/jablib/src/main/resources/l10n/JabRef_fi.properties b/jablib/src/main/resources/l10n/JabRef_fi.properties index e875d986180a..f5c9c41ec3ca 100644 --- a/jablib/src/main/resources/l10n/JabRef_fi.properties +++ b/jablib/src/main/resources/l10n/JabRef_fi.properties @@ -1991,6 +1991,8 @@ Pseudonymization=Pseudonymisointi # Pseudonymizing via CLI +# jabkit git merge-driver + # Quick Settings Quick\ settings=Pika-asetukset diff --git a/jablib/src/main/resources/l10n/JabRef_fr.properties b/jablib/src/main/resources/l10n/JabRef_fr.properties index eec052082902..ddbd2a558285 100644 --- a/jablib/src/main/resources/l10n/JabRef_fr.properties +++ b/jablib/src/main/resources/l10n/JabRef_fr.properties @@ -3505,6 +3505,21 @@ Pseudonymize\ the\ current\ library\ and\ generate\ a\ .bib\ and\ .csv\ file=Pse Pseudonymizing\ library\ '%0'...=Pseudonymisation du fichier '%0'... Invalid\ output\ file\ type\ provided.=Type de fichier de sortie fourni invalide. +# jabkit git merge-driver +Unable\ to\ merge\ %0.=Impossible de fusionner %0. +Merged\ %0\ without\ conflicts.=%0 fusionné sans conflit. +%0\ entries\ could\ not\ be\ merged\ automatically\:=%0 entrées n'ont pas pu être fusionnées automatiquement \: +%0\:\ deleted\ in\ CURRENT,\ changed\ in\ OTHER=%0 \: supprimé de ACTUEL, changé dans AUTRE +%0\:\ changed\ in\ CURRENT,\ deleted\ in\ OTHER=%0 \: changé dans ACTUEL, supprimé de AUTRE +%0\:\ added\ on\ both\ sides\ with\ different\ content=%0 \: ajouté des deux côtés avec un contenu différent +%0\:\ changed\ on\ both\ sides\ with\ different\ content=%0 \: supprimé des deux côtés avec un contenu différent +Cannot\ merge\ %0\:\ citation\ keys\ must\ be\ unique.=Impossible de fusionner %0 \: les clés de citation doivent être uniques. +Cannot\ merge\ %0\:\ the\ file\ was\ not\ parsed\ without\ warnings.=Impossible de fusionner %0 \: le fichier n'a pas été analysé sans avertissement. +Cannot\ merge\ %0\:\ an\ entry\ without\ fields\ is\ not\ preserved.=Impossible de fusionner %0 \: une entrée sans champ n'est pas préservée. +Cannot\ merge\ %0\:\ a\ comment\ in\ front\ of\ @Comment\ or\ @Preamble\ is\ not\ preserved.=Impossible de fusionner %0 \: un commentaire devant @Comment ou @Preamble n'est pas préservé. +Cannot\ merge\ %0\:\ a\ custom\ entry\ type\ without\ entries\ is\ not\ preserved.=Impossible de fusionner %0 \: un type d'entrée personnalisé sans entrée n'est pas préservé. +Cannot\ merge\ %0\:\ content\ outside\ of\ entries\ with\ a\ citation\ key\ changed\ in\ OTHER.=Impossible de fusionner %0 \: contenu en dehors des entrées avec une clé de citation modifiée dans OTHER. + # Quick Settings Quick\ settings=Paramétrage rapide diff --git a/jablib/src/main/resources/l10n/JabRef_id.properties b/jablib/src/main/resources/l10n/JabRef_id.properties index 16b196f75c70..f7fd1e468e29 100644 --- a/jablib/src/main/resources/l10n/JabRef_id.properties +++ b/jablib/src/main/resources/l10n/JabRef_id.properties @@ -1486,6 +1486,8 @@ Related\ articles=Artikel terkait # Pseudonymizing via CLI +# jabkit git merge-driver + # Quick Settings # Quick Settings Buttons diff --git a/jablib/src/main/resources/l10n/JabRef_it.properties b/jablib/src/main/resources/l10n/JabRef_it.properties index 6602db64fe53..3bca3898ca54 100644 --- a/jablib/src/main/resources/l10n/JabRef_it.properties +++ b/jablib/src/main/resources/l10n/JabRef_it.properties @@ -3440,6 +3440,21 @@ Pseudonymize\ the\ current\ library\ and\ generate\ a\ .bib\ and\ .csv\ file=Pse Pseudonymizing\ library\ '%0'...=Pseudonimizzazione libreria '%0'... Invalid\ output\ file\ type\ provided.=Tipo di file di output fornito non valido. +# jabkit git merge-driver +Unable\ to\ merge\ %0.=Impossibile unire %0. +Merged\ %0\ without\ conflicts.=Unito %0 senza conflitti. +%0\ entries\ could\ not\ be\ merged\ automatically\:=Le voci %0 non possono essere unite automaticamente\: +%0\:\ deleted\ in\ CURRENT,\ changed\ in\ OTHER=%0\: eliminato in CURRENT, modificato in ALTRI +%0\:\ changed\ in\ CURRENT,\ deleted\ in\ OTHER=%0\: modificato in CURRENT, eliminato in ALTRI +%0\:\ added\ on\ both\ sides\ with\ different\ content=%0\: aggiunto su entrambi i lati con contenuti diversi +%0\:\ changed\ on\ both\ sides\ with\ different\ content=%0\: modificato su entrambi i lati con contenuti diversi +Cannot\ merge\ %0\:\ citation\ keys\ must\ be\ unique.=Impossibile unire %0\: le chiavi di citazione devono essere univoche. +Cannot\ merge\ %0\:\ the\ file\ was\ not\ parsed\ without\ warnings.=Impossibile unire %0\: il file non è stato analizzato senza avvisi. +Cannot\ merge\ %0\:\ an\ entry\ without\ fields\ is\ not\ preserved.=Impossibile unire %0\: una voce senza campi non è conservata. +Cannot\ merge\ %0\:\ a\ comment\ in\ front\ of\ @Comment\ or\ @Preamble\ is\ not\ preserved.=Impossibile unire %0\: un commento di fronte a @Comment o @Preamble non è conservato. +Cannot\ merge\ %0\:\ a\ custom\ entry\ type\ without\ entries\ is\ not\ preserved.=Impossibile unire %0\: un tipo di voce personalizzata senza voci non è conservato. +Cannot\ merge\ %0\:\ content\ outside\ of\ entries\ with\ a\ citation\ key\ changed\ in\ OTHER.=Impossibile unire %0\: contenuto al di fuori delle voci con una chiave di citazione cambiata in ALTRA. + # Quick Settings Quick\ settings=Impostazioni rapide diff --git a/jablib/src/main/resources/l10n/JabRef_ja.properties b/jablib/src/main/resources/l10n/JabRef_ja.properties index 0ac48e9eb507..c375eea37c59 100644 --- a/jablib/src/main/resources/l10n/JabRef_ja.properties +++ b/jablib/src/main/resources/l10n/JabRef_ja.properties @@ -2184,6 +2184,8 @@ Related\ articles=関連文献 # Pseudonymizing via CLI +# jabkit git merge-driver + # Quick Settings # Quick Settings Buttons diff --git a/jablib/src/main/resources/l10n/JabRef_ko.properties b/jablib/src/main/resources/l10n/JabRef_ko.properties index 9bb101ec0efa..11e9081ce8f3 100644 --- a/jablib/src/main/resources/l10n/JabRef_ko.properties +++ b/jablib/src/main/resources/l10n/JabRef_ko.properties @@ -2046,6 +2046,8 @@ Related\ articles=관련 글 # Pseudonymizing via CLI +# jabkit git merge-driver + # Quick Settings # Quick Settings Buttons diff --git a/jablib/src/main/resources/l10n/JabRef_nl.properties b/jablib/src/main/resources/l10n/JabRef_nl.properties index ec5a29914948..413d6fac6cf3 100644 --- a/jablib/src/main/resources/l10n/JabRef_nl.properties +++ b/jablib/src/main/resources/l10n/JabRef_nl.properties @@ -2267,6 +2267,8 @@ Related\ articles=Gerelateerde artikelen # Pseudonymizing via CLI +# jabkit git merge-driver + # Quick Settings # Quick Settings Buttons diff --git a/jablib/src/main/resources/l10n/JabRef_no.properties b/jablib/src/main/resources/l10n/JabRef_no.properties index df77e6b2d1a3..28c8d36d6884 100644 --- a/jablib/src/main/resources/l10n/JabRef_no.properties +++ b/jablib/src/main/resources/l10n/JabRef_no.properties @@ -1105,6 +1105,8 @@ Link=Lenke # Pseudonymizing via CLI +# jabkit git merge-driver + # Quick Settings # Quick Settings Buttons diff --git a/jablib/src/main/resources/l10n/JabRef_pl.properties b/jablib/src/main/resources/l10n/JabRef_pl.properties index 2ee3471e1c25..8d089a7c9538 100644 --- a/jablib/src/main/resources/l10n/JabRef_pl.properties +++ b/jablib/src/main/resources/l10n/JabRef_pl.properties @@ -1775,6 +1775,8 @@ Exclude=Wyklucz # Pseudonymizing via CLI +# jabkit git merge-driver + # Quick Settings # Quick Settings Buttons diff --git a/jablib/src/main/resources/l10n/JabRef_pt.properties b/jablib/src/main/resources/l10n/JabRef_pt.properties index d718d8f3bfd8..8ab858de754d 100644 --- a/jablib/src/main/resources/l10n/JabRef_pt.properties +++ b/jablib/src/main/resources/l10n/JabRef_pt.properties @@ -1323,6 +1323,8 @@ Related\ articles=Artigos relacionados # Pseudonymizing via CLI +# jabkit git merge-driver + # Quick Settings # Quick Settings Buttons diff --git a/jablib/src/main/resources/l10n/JabRef_pt_BR.properties b/jablib/src/main/resources/l10n/JabRef_pt_BR.properties index 5aa9339b7932..abc812e075ba 100644 --- a/jablib/src/main/resources/l10n/JabRef_pt_BR.properties +++ b/jablib/src/main/resources/l10n/JabRef_pt_BR.properties @@ -3034,6 +3034,8 @@ Pseudonymize\ the\ current\ library\ and\ generate\ a\ .bib\ and\ .csv\ file=Pse Pseudonymizing\ library\ '%0'...=Pseudonimizando biblioteca '%0'... Invalid\ output\ file\ type\ provided.=Tipo de arquivo de saída fornecido inválido. +# jabkit git merge-driver + # Quick Settings Quick\ settings=Configurações rápidas diff --git a/jablib/src/main/resources/l10n/JabRef_ru.properties b/jablib/src/main/resources/l10n/JabRef_ru.properties index a6aaa859358f..576d38c6f92c 100644 --- a/jablib/src/main/resources/l10n/JabRef_ru.properties +++ b/jablib/src/main/resources/l10n/JabRef_ru.properties @@ -2797,6 +2797,8 @@ Click\ the\ "Get\ fulltext"\ button\ (second\ button\ with\ a\ download\ icon)\ # Pseudonymizing via CLI +# jabkit git merge-driver + # Quick Settings # Quick Settings Buttons diff --git a/jablib/src/main/resources/l10n/JabRef_sv.properties b/jablib/src/main/resources/l10n/JabRef_sv.properties index 96ac9230b6d4..e00e0b96a36f 100644 --- a/jablib/src/main/resources/l10n/JabRef_sv.properties +++ b/jablib/src/main/resources/l10n/JabRef_sv.properties @@ -1461,6 +1461,8 @@ Warning\:\ The\ selected\ directory\ is\ not\ a\ valid\ directory.=Varning\: Den # Pseudonymizing via CLI +# jabkit git merge-driver + # Quick Settings # Quick Settings Buttons diff --git a/jablib/src/main/resources/l10n/JabRef_tl.properties b/jablib/src/main/resources/l10n/JabRef_tl.properties index 234a89837c82..7f2a8c6f4fac 100644 --- a/jablib/src/main/resources/l10n/JabRef_tl.properties +++ b/jablib/src/main/resources/l10n/JabRef_tl.properties @@ -1228,6 +1228,8 @@ Related\ articles=Kaugnay na mga artikulo # Pseudonymizing via CLI +# jabkit git merge-driver + # Quick Settings # Quick Settings Buttons diff --git a/jablib/src/main/resources/l10n/JabRef_tr.properties b/jablib/src/main/resources/l10n/JabRef_tr.properties index a32df3a58f0c..9dc3f694c37d 100644 --- a/jablib/src/main/resources/l10n/JabRef_tr.properties +++ b/jablib/src/main/resources/l10n/JabRef_tr.properties @@ -2796,6 +2796,8 @@ More\ options...=Daha fazla seçenekler... # Pseudonymizing via CLI +# jabkit git merge-driver + # Quick Settings # Quick Settings Buttons diff --git a/jablib/src/main/resources/l10n/JabRef_uk.properties b/jablib/src/main/resources/l10n/JabRef_uk.properties index 87051f0d0cf2..4b8388a7a059 100644 --- a/jablib/src/main/resources/l10n/JabRef_uk.properties +++ b/jablib/src/main/resources/l10n/JabRef_uk.properties @@ -726,6 +726,8 @@ Could\ not\ export\ preferences=Не вдалося експортувати н # Pseudonymizing via CLI +# jabkit git merge-driver + # Quick Settings # Quick Settings Buttons diff --git a/jablib/src/main/resources/l10n/JabRef_vi.properties b/jablib/src/main/resources/l10n/JabRef_vi.properties index 85d64e1819ce..7d3ce98969d0 100644 --- a/jablib/src/main/resources/l10n/JabRef_vi.properties +++ b/jablib/src/main/resources/l10n/JabRef_vi.properties @@ -1050,6 +1050,8 @@ This\ could\ indicate\ that\ JabRef\ did\ not\ shut\ down\ cleanly\ last\ time\ # Pseudonymizing via CLI +# jabkit git merge-driver + # Quick Settings # Quick Settings Buttons diff --git a/jablib/src/main/resources/l10n/JabRef_zh_CN.properties b/jablib/src/main/resources/l10n/JabRef_zh_CN.properties index 8d95c399454e..b1bccaba0ca3 100644 --- a/jablib/src/main/resources/l10n/JabRef_zh_CN.properties +++ b/jablib/src/main/resources/l10n/JabRef_zh_CN.properties @@ -3252,6 +3252,8 @@ Pseudonymize\ the\ current\ library\ and\ generate\ a\ .bib\ and\ .csv\ file=匿 Pseudonymizing\ library\ '%0'...=正在匿名化库 '%0'... Invalid\ output\ file\ type\ provided.=提供的输出文件类型无效。 +# jabkit git merge-driver + # Quick Settings Quick\ settings=快速设置 diff --git a/jablib/src/main/resources/l10n/JabRef_zh_TW.properties b/jablib/src/main/resources/l10n/JabRef_zh_TW.properties index d1ceed322040..8d5a96da3509 100644 --- a/jablib/src/main/resources/l10n/JabRef_zh_TW.properties +++ b/jablib/src/main/resources/l10n/JabRef_zh_TW.properties @@ -1058,6 +1058,8 @@ Related\ articles=相關文章 # Pseudonymizing via CLI +# jabkit git merge-driver + # Quick Settings # Quick Settings Buttons diff --git a/jablib/src/test/java/org/jabref/logic/importer/fileformat/pdf/PdfMergeMetadataImporterTest.java b/jablib/src/test/java/org/jabref/logic/importer/fileformat/pdf/PdfMergeMetadataImporterTest.java index 494ff9449cdc..79947969d02c 100644 --- a/jablib/src/test/java/org/jabref/logic/importer/fileformat/pdf/PdfMergeMetadataImporterTest.java +++ b/jablib/src/test/java/org/jabref/logic/importer/fileformat/pdf/PdfMergeMetadataImporterTest.java @@ -40,7 +40,7 @@ class PdfMergeMetadataImporterTest { void setUp() { GrobidPreferences grobidPreferences = mock(GrobidPreferences.class, Answers.RETURNS_DEEP_STUBS); when(grobidPreferences.isGrobidEnabled()).thenReturn(true); - when(grobidPreferences.getGrobidURL()).thenReturn("http://grobid.jabref.org:8070"); + when(grobidPreferences.getGrobidURL()).thenReturn("https://grobid.jabref.org"); ImportFormatPreferences importFormatPreferences = mock(ImportFormatPreferences.class, Answers.RETURNS_DEEP_STUBS); when(importFormatPreferences.fieldPreferences().getNonWrappableFields()).thenReturn(FXCollections.emptyObservableList()); diff --git a/jablib/src/test/java/org/jabref/logic/importer/util/GrobidServiceTest.java b/jablib/src/test/java/org/jabref/logic/importer/util/GrobidServiceTest.java index c8aee8512638..609c9a7a02ad 100644 --- a/jablib/src/test/java/org/jabref/logic/importer/util/GrobidServiceTest.java +++ b/jablib/src/test/java/org/jabref/logic/importer/util/GrobidServiceTest.java @@ -39,15 +39,15 @@ static void setup() { GrobidPreferences grobidPreferences = new GrobidPreferences( true, false, - "http://grobid.jabref.org:8070"); + "https://grobid.jabref.org"); grobidService = new GrobidService(grobidPreferences); } @Test void processValidCitationTest() throws IOException, ParseException { BibEntry exampleBibEntry = new BibEntry(StandardEntryType.Article) - .withCitationKey("-1") - .withField(StandardField.AUTHOR, "Derwing, Tracey and Rossiter, Marian and Munro, Murray") + .withCitationKey("derwing2002teaching") + .withField(StandardField.AUTHOR, "Derwing, Tracey M. and Rossiter, Marian J. and Munro, Murray J.") .withField(StandardField.TITLE, "Teaching Native Speakers to Listen to Foreign-accented Speech") .withField(StandardField.JOURNAL, "Journal of Multilingual and Multicultural Development") .withField(StandardField.PUBLISHER, "Informa UK Limited") @@ -85,7 +85,7 @@ void failsWhenGrobidDisabled() { GrobidPreferences importSettingsWithGrobidDisabled = new GrobidPreferences( false, false, - "http://grobid.jabref.org:8070"); + "https://grobid.jabref.org"); assertThrows(UnsupportedOperationException.class, () -> new GrobidService(importSettingsWithGrobidDisabled)); } @@ -103,7 +103,7 @@ void processPdfTest() throws IOException, ParseException, URISyntaxException { @Test void extractsReferencesFromPdf() throws IOException, ParseException, URISyntaxException { BibEntry ref1 = new BibEntry(StandardEntryType.Article) - .withField(StandardField.AUTHOR, "Kopp, O") + .withField(StandardField.AUTHOR, "Kopp, O.") .withField(StandardField.ADDRESS, "Berlin; Heidelberg") .withField(StandardField.DATE, "2013") .withField(StandardField.JOURNAL, "All links were last followed on October") diff --git a/jablib/src/test/java/org/jabref/logic/preferences/PreferenceMigrationTest.java b/jablib/src/test/java/org/jabref/logic/preferences/PreferenceMigrationTest.java index 03d3e131bf24..6123f088f0ca 100644 --- a/jablib/src/test/java/org/jabref/logic/preferences/PreferenceMigrationTest.java +++ b/jablib/src/test/java/org/jabref/logic/preferences/PreferenceMigrationTest.java @@ -20,14 +20,18 @@ class PreferenceMigrationTest { private static final String AI_ANSWER_ENGINE_KIND = "aiAnswerEngineKind"; private static final String AI_RESPONSE_ENGINE_KIND = "aiResponseEngineKind"; private static final String AI_EMBEDDING_MODEL = "aiEmbeddingModel"; + private static final String LEGACY_GROBID_URL = "http://grobid.jabref.org:8070"; + private static final String HTTPS_GROBID_URL = "https://grobid.jabref.org"; private static final String UNUSED_DEFAULT_VALUE = ""; private boolean hasLegacyResponseEngineKindValue; private boolean hasResponseEngineKindValue; private boolean hasEmbeddingModelValue; + private boolean hasGrobidUrlValue; private String legacyResponseEngineKindValue; private String responseEngineKindValue; private String embeddingModelValue; + private String grobidUrlValue; @BeforeEach void setUp() { @@ -36,13 +40,16 @@ void setUp() { hasLegacyResponseEngineKindValue = preferences.hasKey(AI_ANSWER_ENGINE_KIND); hasResponseEngineKindValue = preferences.hasKey(AI_RESPONSE_ENGINE_KIND); hasEmbeddingModelValue = preferences.hasKey(AI_EMBEDDING_MODEL); + hasGrobidUrlValue = preferences.hasKey(JabRefCliPreferences.GROBID_URL); legacyResponseEngineKindValue = preferences.get(AI_ANSWER_ENGINE_KIND, UNUSED_DEFAULT_VALUE); responseEngineKindValue = preferences.get(AI_RESPONSE_ENGINE_KIND, UNUSED_DEFAULT_VALUE); embeddingModelValue = preferences.get(AI_EMBEDDING_MODEL, UNUSED_DEFAULT_VALUE); + grobidUrlValue = preferences.get(JabRefCliPreferences.GROBID_URL, UNUSED_DEFAULT_VALUE); preferences.remove(AI_ANSWER_ENGINE_KIND); preferences.remove(AI_RESPONSE_ENGINE_KIND); preferences.remove(AI_EMBEDDING_MODEL); + preferences.remove(JabRefCliPreferences.GROBID_URL); } @AfterEach @@ -52,6 +59,7 @@ void tearDown() { restorePreference(preferences, AI_ANSWER_ENGINE_KIND, hasLegacyResponseEngineKindValue, legacyResponseEngineKindValue); restorePreference(preferences, AI_RESPONSE_ENGINE_KIND, hasResponseEngineKindValue, responseEngineKindValue); restorePreference(preferences, AI_EMBEDDING_MODEL, hasEmbeddingModelValue, embeddingModelValue); + restorePreference(preferences, JabRefCliPreferences.GROBID_URL, hasGrobidUrlValue, grobidUrlValue); } @Test @@ -100,6 +108,29 @@ void getAiPreferencesMigratesOtherLegacyEmbeddingModelToDefault() { assertEquals(AiDefaultExpertSettings.EMBEDDING_MODEL, preferences.get(AI_EMBEDDING_MODEL, UNUSED_DEFAULT_VALUE)); } + @Test + void migrateGrobidUrlMigratesLegacyUrl() { + JabRefCliPreferences preferences = new JabRefCliPreferences(); + preferences.put(JabRefCliPreferences.GROBID_URL, LEGACY_GROBID_URL); + + GrobidUrlMigration.migrate(preferences); + + assertEquals(HTTPS_GROBID_URL, preferences.get(JabRefCliPreferences.GROBID_URL, UNUSED_DEFAULT_VALUE)); + assertEquals(HTTPS_GROBID_URL, preferences.getGrobidPreferences().getGrobidURL()); + } + + @Test + void migrateGrobidUrlKeepsCustomUrl() { + String customGrobidUrl = "https://grobid.example.org"; + JabRefCliPreferences preferences = new JabRefCliPreferences(); + preferences.put(JabRefCliPreferences.GROBID_URL, customGrobidUrl); + + GrobidUrlMigration.migrate(preferences); + + assertEquals(customGrobidUrl, preferences.get(JabRefCliPreferences.GROBID_URL, UNUSED_DEFAULT_VALUE)); + assertEquals(customGrobidUrl, preferences.getGrobidPreferences().getGrobidURL()); + } + private void restorePreference(JabRefCliPreferences preferences, String key, boolean hasValue, String value) { if (!hasValue) { preferences.remove(key); diff --git a/jablib/src/test/java/org/jabref/logic/search/inmemory/InMemoryLuceneSearchBackendTest.java b/jablib/src/test/java/org/jabref/logic/search/inmemory/InMemoryLuceneSearchBackendTest.java index 3d0e061ed529..99e47955cc42 100644 --- a/jablib/src/test/java/org/jabref/logic/search/inmemory/InMemoryLuceneSearchBackendTest.java +++ b/jablib/src/test/java/org/jabref/logic/search/inmemory/InMemoryLuceneSearchBackendTest.java @@ -8,6 +8,7 @@ import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; +import java.util.stream.Stream; import org.jabref.logic.FilePreferences; import org.jabref.logic.importer.ImportFormatPreferences; @@ -27,6 +28,8 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; import org.junit.jupiter.params.provider.ValueSource; import org.mockito.Answers; @@ -59,7 +62,26 @@ void searchesLinkedFileContentsWithoutPostgres() throws IOException, URISyntaxEx search("comma", EnumSet.of(SearchFlags.FULLTEXT))); } - /// [Issue 9482](https://github.com/JabRef/jabref/issues/9482): a quotation mark used to make the search throw. + static Stream searchesCaseSensitively() { + return Stream.of( + Arguments.of(Set.of("minimal-sentence-case", "minimal-mixed-case"), "any =! comma"), + Arguments.of(Set.of("minimal-all-upper-case"), "any =! COMMA"), + Arguments.of(Set.of(), "any =! Comma"), + Arguments.of(Set.of("minimal-note-sentence-case"), "any ==! Hello"), + Arguments.of(Set.of("minimal-note-all-upper-case"), "any ==! HELLO"), + Arguments.of(Set.of("minimal-sentence-case", "minimal-mixed-case"), "any =~! comm."), + Arguments.of(Set.of("minimal-all-upper-case"), "any =~! COMM."), + Arguments.of(Set.of(), "any =~! Comm.") + ); + } + + // [utest->req~jabgui.search.fulltext.case-sensitive~1] + @ParameterizedTest + @MethodSource + void searchesCaseSensitively(Set expectedCitationKeys, String searchExpression) throws IOException, URISyntaxException { + assertEquals(expectedCitationKeys, search(searchExpression, EnumSet.of(SearchFlags.FULLTEXT))); + } + @Test void searchesPhraseInLinkedFileContents() throws IOException, URISyntaxException { assertEquals( diff --git a/jablib/src/test/java/org/jabref/logic/search/query/SearchQueryLuceneConversionTest.java b/jablib/src/test/java/org/jabref/logic/search/query/SearchQueryLuceneConversionTest.java index d305f38ffea4..1fbd253be00c 100644 --- a/jablib/src/test/java/org/jabref/logic/search/query/SearchQueryLuceneConversionTest.java +++ b/jablib/src/test/java/org/jabref/logic/search/query/SearchQueryLuceneConversionTest.java @@ -1,9 +1,12 @@ package org.jabref.logic.search.query; +import java.util.EnumSet; import java.util.stream.Stream; +import org.jabref.model.search.SearchFlags; import org.jabref.model.search.query.SearchQuery; +import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; @@ -20,9 +23,11 @@ public static Stream searchConversion() { Arguments.of("term", "any = term"), Arguments.of("term", "any CONTAINS term"), Arguments.of("term", "any MATCHES term"), - Arguments.of("term", "any =! term"), + Arguments.of("(contentCaseSensitive:term OR annotationsCaseSensitive:term)", "any =! term"), Arguments.of("term", "any == term"), - Arguments.of("term", "any ==! term"), + Arguments.of("(contentCaseSensitive:term OR annotationsCaseSensitive:term)", "any ==! term"), + Arguments.of("contentCaseSensitive:Term", "content =! Term"), + Arguments.of("annotationsCaseSensitive:Term", "annotations ==! Term"), Arguments.of("\"two terms\"", "\"two terms\""), Arguments.of("\"two terms\"", "any = \"two terms\""), @@ -37,7 +42,7 @@ public static Stream searchConversion() { Arguments.of("NOT term", "any != term"), Arguments.of("NOT term", "any !== term"), - Arguments.of("NOT term", "any !=! term"), + Arguments.of("NOT (contentCaseSensitive:term OR annotationsCaseSensitive:term)", "any !=! term"), Arguments.of("NOT \"two terms\"", "any != \"two terms\""), Arguments.of("content:image AND NOT annotations:processing", "content = image AND annotations != processing"), @@ -65,4 +70,11 @@ public static Stream searchConversion() { void searchConversion(String expected, String searchExpression) { assertEquals(expected, SearchQueryConversion.searchToLucene(new SearchQuery(searchExpression))); } + + /// The case-sensitivity toggle of the search bar applies to unfielded terms. + @Test + void caseSensitiveSearchBarFlagAppliesToUnfieldedTerm() { + assertEquals("(contentCaseSensitive:Term OR annotationsCaseSensitive:Term)", + SearchQueryConversion.searchToLucene(new SearchQuery("Term", EnumSet.of(SearchFlags.CASE_SENSITIVE)))); + } } diff --git a/jablib/src/test/java/org/jabref/logic/search/sqlbased/SqlBasedLibrarySearcherWithBibFilesTest.java b/jablib/src/test/java/org/jabref/logic/search/sqlbased/SqlBasedLibrarySearcherWithBibFilesTest.java index f19e986e6e5a..00c195e3d055 100644 --- a/jablib/src/test/java/org/jabref/logic/search/sqlbased/SqlBasedLibrarySearcherWithBibFilesTest.java +++ b/jablib/src/test/java/org/jabref/logic/search/sqlbased/SqlBasedLibrarySearcherWithBibFilesTest.java @@ -149,7 +149,15 @@ private static Stream searchLibrary() { Arguments.of(List.of(), "test-library-with-attached-files.bib", "NotExisting", true), Arguments.of(List.of(MINIMAL_NOTE_SENTENCE_CASE, MINIMAL_NOTE_ALL_UPPER_CASE, MINIMAL_NOTE_MIXED_CASE), "test-library-with-attached-files.bib", "world", true), - Arguments.of(List.of(MINIMAL_NOTE_SENTENCE_CASE, MINIMAL_NOTE_ALL_UPPER_CASE, MINIMAL_NOTE_MIXED_CASE), "test-library-with-attached-files.bib", "\"Hello World\"", true) + Arguments.of(List.of(MINIMAL_NOTE_SENTENCE_CASE, MINIMAL_NOTE_ALL_UPPER_CASE, MINIMAL_NOTE_MIXED_CASE), "test-library-with-attached-files.bib", "\"Hello World\"", true), + + // case-sensitive fulltext search + Arguments.of(List.of(MINIMAL_ALL_UPPER_CASE), "test-library-with-attached-files.bib", "any =! COMMA", true), + Arguments.of(List.of(MINIMAL_SENTENCE_CASE, MINIMAL_MIXED_CASE), "test-library-with-attached-files.bib", "any =! comma", true), + Arguments.of(List.of(MINIMAL_NOTE_ALL_UPPER_CASE), "test-library-with-attached-files.bib", "any ==! HELLO", true), + Arguments.of(List.of(MINIMAL_NOTE_SENTENCE_CASE), "test-library-with-attached-files.bib", "any ==! Hello", true), + Arguments.of(List.of(MINIMAL_ALL_UPPER_CASE), "test-library-with-attached-files.bib", "any =~! COMM.", true), + Arguments.of(List.of(MINIMAL_SENTENCE_CASE, MINIMAL_MIXED_CASE), "test-library-with-attached-files.bib", "any =~! comm.", true) ); } diff --git a/jablib/src/test/java/org/jabref/model/search/query/SearchResultTest.java b/jablib/src/test/java/org/jabref/model/search/query/SearchResultTest.java new file mode 100644 index 000000000000..2c3424993698 --- /dev/null +++ b/jablib/src/test/java/org/jabref/model/search/query/SearchResultTest.java @@ -0,0 +1,46 @@ +package org.jabref.model.search.query; + +import java.util.List; + +import org.apache.lucene.index.Term; +import org.apache.lucene.search.BooleanClause; +import org.apache.lucene.search.BooleanQuery; +import org.apache.lucene.search.TermQuery; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class SearchResultTest { + + private static final String CONTENT = "This sentence, comma included. THIS SENTENCE, COMMA INCLUDED."; + + @Test + void caseInsensitiveTermHighlightsAllSpellings() { + SearchResult result = new SearchResult("file.pdf", CONTENT, "", 1, new TermQuery(new Term("content", "comma"))); + assertEquals(List.of("This sentence, comma included. THIS SENTENCE, COMMA INCLUDED."), result.getContentResultStringsHtml()); + } + + @Test + void caseSensitiveTermHighlightsOnlyThatSpelling() { + SearchResult result = new SearchResult("file.pdf", CONTENT, "", 1, new TermQuery(new Term("contentCaseSensitive", "comma"))); + assertEquals(List.of("This sentence, comma included. THIS SENTENCE, COMMA INCLUDED."), result.getContentResultStringsHtml()); + } + + @Test + void compoundQueryHighlightsBothKindsOfTerms() { + BooleanQuery query = new BooleanQuery.Builder() + .add(new TermQuery(new Term("content", "sentenc")), BooleanClause.Occur.MUST) + .add(new TermQuery(new Term("contentCaseSensitive", "COMMA")), BooleanClause.Occur.MUST) + .build(); + SearchResult result = new SearchResult("file.pdf", CONTENT, "", 1, query); + assertEquals(List.of( + "This sentence, comma included. THIS SENTENCE, COMMA INCLUDED.", + "This sentence, comma included. THIS SENTENCE, COMMA INCLUDED."), result.getContentResultStringsHtml()); + } + + @Test + void caseSensitiveAnnotationTermHighlightsOnlyThatSpelling() { + SearchResult result = new SearchResult("file.pdf", "", "Hello HELLO", 1, new TermQuery(new Term("annotationsCaseSensitive", "Hello"))); + assertEquals(List.of("Hello HELLO"), result.getAnnotationsResultStringsHtml()); + } +} diff --git a/versions/build.gradle.kts b/versions/build.gradle.kts index 5fbcfec4a6e9..17eeed5c6a4d 100644 --- a/versions/build.gradle.kts +++ b/versions/build.gradle.kts @@ -135,7 +135,7 @@ dependencies.constraints { api("org.glassfish.hk2:hk2-utils:4.0.2") api("org.glassfish.jaxb:jaxb-runtime:4.0.9") api("org.hamcrest:hamcrest:3.0") - api("org.hibernate.validator:hibernate-validator:9.1.3.Final") + api("org.hibernate.validator:hibernate-validator:9.1.4.Final") api("org.hisp.dhis:json-tree:1.9.5") api("org.jabref:afterburner.fx:2.0.0") api("org.jabref:easybind:2.3.0")