From 6fb29ed213ef6bdabaf9b3898477c5640de6cd77 Mon Sep 17 00:00:00 2001 From: albert Date: Thu, 8 Oct 2026 19:08:27 -0700 Subject: [PATCH 1/4] Bump version to v0.6.2 --- lib/src/app/app_metadata.dart | 2 +- linux/io.busystack.busymark.metainfo.xml | 1 + pubspec.yaml | 2 +- snap/snapcraft.yaml | 2 +- test/src/visualization_packaging_audit_test.dart | 6 +++--- 5 files changed, 7 insertions(+), 6 deletions(-) diff --git a/lib/src/app/app_metadata.dart b/lib/src/app/app_metadata.dart index ac974a9b..eb14e97a 100644 --- a/lib/src/app/app_metadata.dart +++ b/lib/src/app/app_metadata.dart @@ -1 +1 @@ -const busyMarkAppVersion = '0.6.1'; +const busyMarkAppVersion = '0.6.2'; diff --git a/linux/io.busystack.busymark.metainfo.xml b/linux/io.busystack.busymark.metainfo.xml index c3062fa2..9166eb5c 100644 --- a/linux/io.busystack.busymark.metainfo.xml +++ b/linux/io.busystack.busymark.metainfo.xml @@ -88,6 +88,7 @@ https://github.com/busystack/busymark/issues + diff --git a/pubspec.yaml b/pubspec.yaml index 8d465423..6af14e7c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: busymark description: Local-first Markdown and Writerside-compatible documentation editor. publish_to: 'none' -version: 0.6.1 +version: 0.6.2 environment: sdk: ">=3.12.1 <4.0.0" diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index ff9ff326..5c3d1462 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -1,6 +1,6 @@ name: busymark title: BusyMark -version: "0.6.1" +version: "0.6.2" summary: Markdown and Writerside documentation editor # Snap Store listing translations are managed outside this Flutter package. # Update store metadata when approved translated listing text is supplied. diff --git a/test/src/visualization_packaging_audit_test.dart b/test/src/visualization_packaging_audit_test.dart index 5643f67a..73fe0a8a 100644 --- a/test/src/visualization_packaging_audit_test.dart +++ b/test/src/visualization_packaging_audit_test.dart @@ -29,10 +29,10 @@ void main() { 'linux/io.busystack.busymark.metainfo.xml', ).readAsStringSync(); - expect(pubspec, contains(RegExp(r'^version: 0\.6\.1$', multiLine: true))); + expect(pubspec, contains(RegExp(r'^version: 0\.6\.2$', multiLine: true))); expect( snapcraft, - contains(RegExp(r'^version: "0\.6\.1"$', multiLine: true)), + contains(RegExp(r'^version: "0\.6\.2"$', multiLine: true)), ); expect(snapcraft, contains(RegExp(r'^grade: stable$', multiLine: true))); expect( @@ -57,7 +57,7 @@ void main() { ), ), ); - expect(metainfo, contains(' Date: Sun, 11 Oct 2026 02:38:04 -0700 Subject: [PATCH 2/4] Feature/nextcloud notes workspace (#51) * Implement Writerside authoring and XML topic visual editing * Refine Writerside switcher label front-matter encoding and property selection * Change snapcraft badge link in README.md * Fix Writerside namespace context and complete authoring acceptance Use authored parent prefixes for generated XML descendants. Restore editing focus after menu cancellation and retain Markdown variable references on reopen and source selection. Extend regressions, builder sample output, and the real GTK acceptance harness. * feat(nextcloud): complete Notes synchronization milestone 1 * docs(nextcloud): record post-commit milestone verification * fix(nextcloud): correct reconnect, metadata, throttling and Unicode flows * docs(nextcloud): record correction post-commit verification * Keep metadata reviews blocked across older write responses * Record post-commit metadata race verification * feat(notes): complete everyday workspace organization and portability * fix(notes): fence portable media snapshots and honor UI audits * test(notes): exercise stable multiselection through production widgets * test(notes): record milestone 2 desktop and performance acceptance * test(notes): verify offline process restart before publication * Fix Notes workspace search, import operations, offline status and recovery * Record verified Milestone 2 correctness follow-up evidence * Implement reference-aware authoring and safe local link maintenance * Correct native refactoring history and installation-aware note URLs * Preserve hidden link sources and fence incremental reference results * Record Milestone 3 release and installed-package acceptance * Fence acknowledged note reads and preserve move identities in recovery * Record corrected-source regression and clean package acceptance * Remove Writerside label from editor toolbar --- README.md | 22 +- .../nextcloud-notes-api-coverage.md | 39 + .../nextcloud-notes-milestone-1.md | 125 + .../nextcloud-notes-milestone-2.md | 322 ++ .../nextcloud-notes-milestone-3.md | 326 ++ docs/development/nextcloud-notes.md | 224 +- docs/references.md | 81 + docs/search-and-replace.md | 26 +- lib/l10n/app_ar.arb | 94 +- lib/l10n/app_de.arb | 94 +- lib/l10n/app_en.arb | 101 +- lib/l10n/app_es.arb | 94 +- lib/l10n/app_et.arb | 94 +- lib/l10n/app_fa.arb | 94 +- lib/l10n/app_fr.arb | 94 +- lib/l10n/app_hi.arb | 94 +- lib/l10n/app_id.arb | 94 +- lib/l10n/app_it.arb | 94 +- lib/l10n/app_ja.arb | 94 +- lib/l10n/app_ko.arb | 94 +- lib/l10n/app_nb.arb | 94 +- lib/l10n/app_nl.arb | 94 +- lib/l10n/app_pl.arb | 94 +- lib/l10n/app_pt.arb | 94 +- lib/l10n/app_pt_BR.arb | 94 +- lib/l10n/app_ru.arb | 94 +- lib/l10n/app_tr.arb | 94 +- lib/l10n/app_uk.arb | 94 +- lib/l10n/app_vi.arb | 94 +- lib/l10n/app_zh.arb | 94 +- lib/l10n/app_zh_CN.arb | 94 +- lib/l10n/generated/app_localizations.dart | 540 +++ lib/l10n/generated/app_localizations_ar.dart | 278 ++ lib/l10n/generated/app_localizations_de.dart | 280 ++ lib/l10n/generated/app_localizations_en.dart | 278 ++ lib/l10n/generated/app_localizations_es.dart | 278 ++ lib/l10n/generated/app_localizations_et.dart | 278 ++ lib/l10n/generated/app_localizations_fa.dart | 278 ++ lib/l10n/generated/app_localizations_fr.dart | 279 ++ lib/l10n/generated/app_localizations_hi.dart | 279 ++ lib/l10n/generated/app_localizations_id.dart | 278 ++ lib/l10n/generated/app_localizations_it.dart | 280 ++ lib/l10n/generated/app_localizations_ja.dart | 274 ++ lib/l10n/generated/app_localizations_ko.dart | 275 ++ lib/l10n/generated/app_localizations_nb.dart | 279 ++ lib/l10n/generated/app_localizations_nl.dart | 278 ++ lib/l10n/generated/app_localizations_pl.dart | 278 ++ lib/l10n/generated/app_localizations_pt.dart | 556 +++ lib/l10n/generated/app_localizations_ru.dart | 278 ++ lib/l10n/generated/app_localizations_tr.dart | 278 ++ lib/l10n/generated/app_localizations_uk.dart | 278 ++ lib/l10n/generated/app_localizations_vi.dart | 278 ++ lib/l10n/generated/app_localizations_zh.dart | 546 +++ lib/src/app/busymark_app.dart | 47 + lib/src/app/busymark_design.dart | 14 +- lib/src/app/busymark_main_menu.dart | 8 + lib/src/app/busymark_shortcuts.dart | 10 + lib/src/app/command_registry.dart | 10 + lib/src/app/quick_open.dart | 245 ++ lib/src/assets/document_media_context.dart | 48 +- lib/src/assets/document_media_resolver.dart | 45 + lib/src/core/linux_file_identity.dart | 55 + lib/src/editor/inline_semantics.dart | 2 + lib/src/editor/link_destination.dart | 6 + lib/src/editor/source/source_commands.dart | 9 +- lib/src/editor/source/source_editor.dart | 55 + .../wysiwyg/writerside_editing_adapter.dart | 857 +++++ .../editor/wysiwyg/writerside_properties.dart | 504 +++ .../editor/wysiwyg/wysiwyg_block_widgets.dart | 14 + .../wysiwyg/wysiwyg_clipboard_fragment.dart | 39 +- lib/src/editor/wysiwyg/wysiwyg_commands.dart | 20 + .../wysiwyg/wysiwyg_document_controller.dart | 1085 +++++- lib/src/editor/wysiwyg/wysiwyg_editor.dart | 718 +++- .../wysiwyg/wysiwyg_inline_controller.dart | 60 +- lib/src/editor/wysiwyg/wysiwyg_toolbar.dart | 104 +- lib/src/export/html_document_writer.dart | 5 + .../export/markdown_copy_export_service.dart | 25 +- lib/src/export/markdown_export_mapper.dart | 4 + lib/src/markdown/busymark_document.dart | 35 + .../busymark_markdown_serializer.dart | 59 + lib/src/markdown/document_outline.dart | 9 +- lib/src/markdown/markdown_ast_adapter.dart | 76 +- lib/src/markdown/markdown_front_matter.dart | 33 + lib/src/markdown/markdown_parser.dart | 30 + lib/src/markdown/markdown_source_map.dart | 2 + lib/src/markdown/math_syntax.dart | 11 +- lib/src/markdown/preview_model.dart | 25 +- .../markdown/writerside_authoring_syntax.dart | 122 + .../application/nextcloud_connection.dart | 60 +- .../application/notes_media.dart | 15 +- .../application/notes_navigation.dart | 122 + .../application/notes_offline_controller.dart | 240 ++ .../application/notes_repository.dart | 1829 +++++++++- .../application/notes_search_controller.dart | 186 + .../notes_settings_controller.dart | 181 + .../application/notes_sync_coordinator.dart | 270 ++ .../application/notes_transfer_service.dart | 667 ++++ .../data/notes_api_client.dart | 408 ++- .../data/notes_capabilities.dart | 98 +- lib/src/nextcloud_notes/data/notes_store.dart | 380 +- lib/src/nextcloud_notes/data/server_uri.dart | 48 +- .../domain/notes_conflict.dart | 22 + .../nextcloud_notes/domain/notes_models.dart | 339 +- .../domain/notes_retrieval.dart | 82 + .../nextcloud_notes/domain/notes_search.dart | 330 ++ .../presentation/nextcloud_settings.dart | 156 +- .../presentation/notes_sidebar.dart | 893 ++++- .../presentation/notes_workspace_ui.dart | 1143 ++++++ lib/src/references/markdown_move_service.dart | 745 ++++ lib/src/references/markdown_move_ui.dart | 160 + lib/src/references/markdown_references.dart | 252 ++ lib/src/references/reference_controller.dart | 302 ++ lib/src/references/reference_index.dart | 366 ++ lib/src/references/reference_ui.dart | 479 +++ .../writerside_reference_adapter.dart | 229 ++ .../spelling_inline_projection.dart | 4 + .../presentation/settings_screen.dart | 1 + .../presentation/welcome_screen.dart | 1 + .../presentation/workspace_screen.dart | 195 +- lib/src/workspace/workspace_controller.dart | 461 ++- lib/src/workspace/workspace_model.dart | 7 + .../writerside_document_parser.dart | 11 +- .../writerside_document_resolver.dart | 63 +- .../writerside_document_serializer.dart | 35 + .../writerside_html_reference_scanner.dart | 83 + lib/src/writerside/writerside_model.dart | 19 +- lib/src/writerside/writerside_project.dart | 31 +- .../reference_conformance/guide.tree | 1 + .../reference_conformance/topics/source.md | 15 + .../reference_conformance/topics/target.topic | 5 + .../reference_conformance/writerside.cfg | 1 + test/src/app_smoke_test.dart | 3 + test/src/busymark_design_test.dart | 5 +- test/src/busymark_document_test.dart | 42 +- test/src/nextcloud_capabilities_test.dart | 6 +- test/src/nextcloud_notes/notes_api_test.dart | 5 +- .../notes_metadata_race_test.dart | 305 ++ .../notes_milestone_repository_test.dart | 977 ++++++ .../nextcloud_notes/notes_milestone_test.dart | 185 + .../notes_repository_test.dart | 4 +- .../nextcloud_notes/notes_retrieval_test.dart | 450 +++ .../notes_review_corrections_test.dart | 524 +++ .../nextcloud_notes/notes_settings_test.dart | 251 ++ .../notes_sync_coordinator_test.dart | 405 +++ .../notes_workspace_m2_test.dart | 1722 +++++++++ test/src/nextcloud_notes_ui_test.dart | 654 +++- test/src/nextcloud_workspace_test.dart | 39 + test/src/notes_workspace_m2_ui_test.dart | 202 ++ .../markdown_move_service_test.dart | 560 +++ .../references/markdown_references_test.dart | 84 + .../references/reference_controller_test.dart | 216 ++ test/src/references/reference_index_test.dart | 286 ++ test/src/references/reference_ui_test.dart | 309 ++ .../writerside_reference_adapter_test.dart | 127 + test/src/source_audit_test.dart | 19 +- test/src/source_editor_widget_test.dart | 33 + test/src/workspace_controller_test.dart | 125 + test/src/writerside_authoring_test.dart | 1733 +++++++++ test/src/writerside_authoring_ui_test.dart | 790 +++++ test/src/writerside_collapsible_test.dart | 6 +- test/src/writerside_video_test.dart | 2 +- test/src/wysiwyg_ai_test.dart | 44 +- test/src/wysiwyg_clipboard_test.dart | 28 +- .../writerside_authoring_native_smoke.dart | 754 ++++ tools/nextcloud_login_live_acceptance.py | 92 +- tools/nextcloud_notes_desktop_acceptance.dart | 3118 +++++++++++++++++ tools/nextcloud_notes_live_acceptance.dart | 1228 ++++++- tools/nextcloud_notes_native_key.py | 52 + tools/nextcloud_notes_performance.dart | 290 ++ tools/validate_writerside_conformance.sh | 12 + tools/verify_writerside_reference_fixture.py | 34 + 171 files changed, 40419 insertions(+), 886 deletions(-) create mode 100644 docs/development/nextcloud-notes-api-coverage.md create mode 100644 docs/development/nextcloud-notes-milestone-1.md create mode 100644 docs/development/nextcloud-notes-milestone-2.md create mode 100644 docs/development/nextcloud-notes-milestone-3.md create mode 100644 docs/references.md create mode 100644 lib/src/app/quick_open.dart create mode 100644 lib/src/assets/document_media_resolver.dart create mode 100644 lib/src/core/linux_file_identity.dart create mode 100644 lib/src/editor/link_destination.dart create mode 100644 lib/src/editor/wysiwyg/writerside_editing_adapter.dart create mode 100644 lib/src/editor/wysiwyg/writerside_properties.dart create mode 100644 lib/src/markdown/writerside_authoring_syntax.dart create mode 100644 lib/src/nextcloud_notes/application/notes_navigation.dart create mode 100644 lib/src/nextcloud_notes/application/notes_offline_controller.dart create mode 100644 lib/src/nextcloud_notes/application/notes_search_controller.dart create mode 100644 lib/src/nextcloud_notes/application/notes_settings_controller.dart create mode 100644 lib/src/nextcloud_notes/application/notes_sync_coordinator.dart create mode 100644 lib/src/nextcloud_notes/application/notes_transfer_service.dart create mode 100644 lib/src/nextcloud_notes/domain/notes_retrieval.dart create mode 100644 lib/src/nextcloud_notes/domain/notes_search.dart create mode 100644 lib/src/nextcloud_notes/presentation/notes_workspace_ui.dart create mode 100644 lib/src/references/markdown_move_service.dart create mode 100644 lib/src/references/markdown_move_ui.dart create mode 100644 lib/src/references/markdown_references.dart create mode 100644 lib/src/references/reference_controller.dart create mode 100644 lib/src/references/reference_index.dart create mode 100644 lib/src/references/reference_ui.dart create mode 100644 lib/src/references/writerside_reference_adapter.dart create mode 100644 test/fixtures/writerside/reference_conformance/guide.tree create mode 100644 test/fixtures/writerside/reference_conformance/topics/source.md create mode 100644 test/fixtures/writerside/reference_conformance/topics/target.topic create mode 100644 test/fixtures/writerside/reference_conformance/writerside.cfg create mode 100644 test/src/nextcloud_notes/notes_metadata_race_test.dart create mode 100644 test/src/nextcloud_notes/notes_milestone_repository_test.dart create mode 100644 test/src/nextcloud_notes/notes_milestone_test.dart create mode 100644 test/src/nextcloud_notes/notes_retrieval_test.dart create mode 100644 test/src/nextcloud_notes/notes_review_corrections_test.dart create mode 100644 test/src/nextcloud_notes/notes_settings_test.dart create mode 100644 test/src/nextcloud_notes/notes_sync_coordinator_test.dart create mode 100644 test/src/nextcloud_notes/notes_workspace_m2_test.dart create mode 100644 test/src/notes_workspace_m2_ui_test.dart create mode 100644 test/src/references/markdown_move_service_test.dart create mode 100644 test/src/references/markdown_references_test.dart create mode 100644 test/src/references/reference_controller_test.dart create mode 100644 test/src/references/reference_index_test.dart create mode 100644 test/src/references/reference_ui_test.dart create mode 100644 test/src/references/writerside_reference_adapter_test.dart create mode 100644 test/src/writerside_authoring_test.dart create mode 100644 test/src/writerside_authoring_ui_test.dart create mode 100644 test/support/writerside_authoring_native_smoke.dart create mode 100644 tools/nextcloud_notes_desktop_acceptance.dart create mode 100644 tools/nextcloud_notes_native_key.py create mode 100644 tools/nextcloud_notes_performance.dart create mode 100644 tools/verify_writerside_reference_fixture.py diff --git a/README.md b/README.md index 7b710201..2e91fa9a 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,13 @@ Markdown, Writerside, and Nextcloud Notes editor for Linux. [![busymark](https://snapcraft.io/busymark/badge.svg)](https://snapcraft.io/busymark) -[![Get it from the Snap Store](https://snapcraft.io/en/dark/install.svg)](https://snapcraft.io/busymark) - + + Get it from the Snap Store +

BusyMark split source and reading view

@@ -15,6 +20,7 @@ Markdown, Writerside, and Nextcloud Notes editor for Linux. * **Markdown editing** — Source, Editor, Reading, and Split views with formatting tools, syntax highlighting, code folding, tables, images, links, code blocks, callouts, collapsible content, and document diagnostics. * **Writerside projects** — Open and create Writerside-compatible projects; edit Markdown and XML topics; manage instances, tables of contents, reusable TOC libraries, and project structure. * **Nextcloud Notes** — Connect to a Nextcloud server, create and edit notes offline, synchronize changes, search and filter notes, manage categories and favorites, and work with attachments. +* **References and links** — Incoming/outgoing references, document/topic/heading target chooser, reviewed local Markdown rename/move, and portable Nextcloud note URLs. See [reference workflows](docs/references.md). * **Navigation and search** — Files, Topics, and Outline views, tabbed documents, command palette, keyboard shortcuts, document search and replace, reviewed replacement across local workspaces, and Markdown TOC generation. * **Technical documentation** — Local rendering of Mermaid, PlantUML, D2, fenced OpenAPI specifications, and MathJax mathematical expressions. * **PDF and HTML publishing** — Export Markdown documents and Writerside instances to configurable PDF or portable offline HTML, with controls for layout, typography, tables of contents, heading numbering, and HTML styling. @@ -97,6 +103,7 @@ the current `snap/snapcraft.yaml` in a clean Ubuntu 24.04 amd64 Snapcraft build environment. The authoritative build sequence is: ```bash +export SNAPCRAFT_BUILD_INFO=1 snapcraft --version snapcraft expand-extensions snapcraft clean @@ -108,6 +115,7 @@ Inspect and test the exact file emitted by the final `snapcraft` command. A failed build must not fall back to an older `.snap`. The `snap` job in [the Linux workflow](.github/workflows/flutter-linux.yml) follows this route, installs that selected artifact, and runs the strict-confinement smoke checks. +Build information embeds the original recipe for the workflow's identity check. `tools/build_install_snap_local.sh` is intentionally different: it replaces the Flutter payload in an installed Snap scaffold for quick development and @@ -203,11 +211,15 @@ Once connected, select **Nextcloud Notes** on the welcome screen to reopen the w ### Work with notes * **Create and edit:** Create notes from the Notes sidebar and edit them using the normal Markdown editor. Use each note’s menu to change its title, category, or favorite status, save a separate local copy, or delete the note. -* **Find and organize:** Search notes by title, content, or category; filter by favorites or category; and see synchronization status in the note list and editor. -* **Work offline:** Edits and new notes are saved to local storage before they are synchronized. Pending changes can survive an application restart. Synchronization runs when a connection is available; the sidebar’s **Refresh** control requests another pass. +* **Find and organize:** Use All notes, Favorites, Uncategorized, Recovery, or a category and its descendants. Sort by activity or title. Ctrl-click toggles selection, Shift-click selects a range, and Ctrl+A selects the current list; the selected-notes action reviews batch favorite/category changes and reports partial outcomes. +* **Quick Open and search:** Ctrl+P finds titles/categories, including unopened cached notes; Ctrl+Shift+P opens the command palette. Notes search combines terms with AND and supports quoted phrases, `title:` / `category:` restrictions, whole words, snippets, and navigation to the match. Unsaved open text is included without forcing a save. +* **Work offline:** Edits and new notes are saved locally before synchronization and survive restart. **Make available offline** retains a note or category/subtree's supported managed media. Its status lists available/missing bytes and offers deliberate retry/cancellation. Offline availability, freshness, and acknowledged synchronization are separate states; external dependencies remain identified. * **Review conflicts:** If a note changes on the server while you have local edits, BusyMark preserves the competing versions for review instead of silently overwriting one. The comparison workflow supports choosing a version, merging changes, or recovering content as a separate note. Ambiguous note-creation results also require explicit review. * **Use attachments:** Add files to notes, view supported images and media, and include managed resources when exporting Markdown or HTML. PDF export includes supported images but does not embed arbitrary file attachments. New uploads are limited to **100 MiB per file**. -* **Export or recover:** Export individual notes to PDF or offline HTML, use **Save As** to create an independent local Markdown copy, and restore earlier content through Local History. +* **Export and import:** Keep per-note **Save local copy**, PDF, and offline HTML exports, or export a category/subtree or account as ordinary Markdown with companion media and a versioned metadata manifest. Dirty buffers must save first; missing media is listed explicitly. Import Markdown files/folders or these snapshots through a review of categories, media, and collisions. Imports create distinct notes and resume successful local items after interruption. +* **Recover:** Recovery lists retained deleted notes and available Local History revisions, previews their content/media availability, and recovers as a new note. It uses local retention settings; Nextcloud server trash/history is unavailable. + +See the [Notes workspace guide](docs/development/nextcloud-notes.md#everyday-notes-workspace) for search syntax, offline requirements, recovery, and the portable snapshot format. ### Requirements and limitations diff --git a/docs/development/nextcloud-notes-api-coverage.md b/docs/development/nextcloud-notes-api-coverage.md new file mode 100644 index 00000000..78f75e7f --- /dev/null +++ b/docs/development/nextcloud-notes-api-coverage.md @@ -0,0 +1,39 @@ +# Notes API coverage + +Contract: Notes API **1.4**, verified Notes **6.0.2** and **6.1.0**. Direct Notes +API integration, one account, schema **6**, existing outbox/coordinator retained. + +| Operation | Coverage | State boundary | +| --- | --- | --- | +| GET full `/api/v1/notes` | Chunk cursors, pruneBefore, collection If-None-Match, final ID-only entries | Only a completed authoritative collection advances checkpoints or infers deletion | +| GET `/api/v1/notes/{id}` | Complete 200 and conditional 304; quoted ETag; unconditional retry path when cache is absent | 304 reuses the captured server base, never acknowledges local edits/conflicts | +| GET filtered/reduced collection | Optional exact category and exclude parameters retained on every chunk; omitted fields represented by presence | Query-scoped projections, no full-account checkpoint/validator/deletion inference | +| GET reduced single note | Server-supported exclude projection; identity checked when included | Projection object, no fabricated NoteState defaults | +| POST/PUT/DELETE note | Existing durable outbox, optimistic conflict handling, uncertainty/retry evidence | Existing accepted synchronization strategy | +| GET/PUT settings | Existing conditional/concurrent settings handling and suffix normalization | Existing settings boundary | +| GET/POST attachment | Existing authenticated API 1.4 retrieval/upload and safe managed-reference publication | Existing ownership, generation and uncertainty protections | +| DELETE attachment | Notes 6.1.0 application capability gate retained | Existing unsupported-version explanation | +| Browser `/note/{id}` | Ordinary HTTPS web navigation, installation prefix, real server ID; public routing observation selects the configured pretty/front-controller form | Existing connected-account opening; cached offline or authenticated repository retrieval | + +A category filter is exact on the supported servers; it is not a descendant +category synchronization strategy. Exclude can omit ID; ID-only entries repeated +by the final server chunk are continuation evidence, not complete projected +notes. Optional reads do not feed the full-account synchronization checkpoint. +The tagged helper always adds ETag and some internal server fields; projections +retain present fields rather than promising that every excluded server field +will disappear. + +The public installation login page's explicit `modRewriteWorking` configuration +selects the web URL form. This anonymous, bounded request never sends account +credentials or follows redirects. A successful HTTP response from the other +form does not prove navigation: on a pretty-URL installation the Notes SPA can +open its most recent note instead. Browser acceptance verifies the intended +note ID and unique content. The optional cached route observation is stored in +the existing account JSON; schema 6 and synchronization checkpoints are unchanged. +BusyMark recognizes both pasted forms internally. Portable heading insertion +is disabled because the verified browser view does not expose BusyMark's heading IDs. + +Deterministic retrieval tests are in +`test/src/nextcloud_notes/notes_retrieval_test.dart`; authenticated fixture checks +are in `tools/nextcloud_notes_live_acceptance.dart`. Exact acceptance results and +limitations are recorded in [Milestone 3](nextcloud-notes-milestone-3.md). diff --git a/docs/development/nextcloud-notes-milestone-1.md b/docs/development/nextcloud-notes-milestone-1.md new file mode 100644 index 00000000..b3616701 --- /dev/null +++ b/docs/development/nextcloud-notes-milestone-1.md @@ -0,0 +1,125 @@ +# Nextcloud Notes Milestone 1 implementation and evidence + +## Baseline + +- Branch `feature/nextcloud-notes-workspace`; initial HEAD `82590bf71abe89123185ba46ebdf754b2d62de02`; initial working tree clean. No applicable ancestor/repository `AGENTS.md`. +- Flutter **3.47.5**, framework `6a19cca564`, Dart **3.13.4**. Project/dependency/native pins retained. Initial locked resolution passed; no upgrade/reset. +- Existing invariants read in `nextcloud-notes.md`. Required tagged Notes v6.1.0 files fetched once to `/tmp/busymark-m1/upstream`, outside the checkout; relevant protocol/controller/settings/service/capability sections reviewed. Flutter lifecycle documentation and RFC 6585 §4 / RFC 9110 §10.2.3 reviewed before implementation. +- Prerequisites checked before implementation: no supplied authorized fixture credentials/CA; Docker available, cached `nextcloud:35-apache`, Linux X11 displays `:1` and isolated `:97`. Dedicated HTTPS fixtures provisioned rather than using ordinary accounts. + +## Work packages and decisions + +| Package | Implementation / evidence | +| --- | --- | +| Scoped errors and retries | Request scope distinguishes collection/settings/capability/note/attachment. Only note evidence establishes deletion. Rejections/permissions/quota block automatic writes; transient retry count and not-before deadlines persist. Delta/date Retry-After never shortened. Creates/uploads keep conservative uncertain outcomes for transport/5xx/invalid responses/locks. | +| Stranded work | Complete restoration listing reconciles poisoned server edits by ETag, with normal conflicts for divergence and no artificial acknowledgment. Drafts require positive durable never-sent evidence for automatic repair; ambiguous legacy histories retain bytes/outbox and expose deliberate separate-note recovery. Genuine complete-list/individual-note/deliberate deletion evidence remains protected. | +| Attachment paths/publication | Raw API filenames remain opaque; Markdown decodes once/encodes segments once. Query construction performs its own encoding. Authoritative upload path commits before reference replacement/conditional PUT, preventing duplicate upload after a local failure/restart. | +| Media freshness | Both byte cache and document context refresh. Remote note changes invalidate freshness; explicit Refresh revalidates active-note cached references even on 304; ordinary freshness is five minutes. Coalesced streaming waits remain outside mutation queue. Failed refresh keeps old bytes/validation time; generations fence removal/disposal/deletion/ownership and newer refreshes. Content-identity materialization removes superseded files. | +| Metadata/time/category | Immutable properties snapshot, changed-field patches, serialized three-way metadata merge, dirty editor saved normally, convergent/no-op changes create no revision. Accepted human edit time persists in microseconds; API modified uses seconds, staging publication retains it, exact uncertain wire body unchanged. Missing historical times use acknowledged server modified only. Activity ordering has UUID ties. Sidebar creates directly in selected parent/category. | +| Coordination/status | One coordinator uses existing repository synchronization: opening/local/manual/focus/resume, 60s visible active polling and authenticated recovery reads. Hidden/inactive workspace polling suspends; visible loss of focus does not. Six retries 5/10/20/40/80/160s, durable budgets; healthy reads cannot release failed writes, but can publish fresh unattempted durable edits. Manual future includes coalesced follow-up. Separate successful complete GET/304 server-check time; local/pending upload/sync/conflict/account failure states. | +| Settings/capabilities | Typed v1 GET/PUT settings; changed fields, custom suffix, normalization, explicit Save/Cancel and preserved dirty forms. Buffers save before serialized transitions; pending/conflicted/attachment work blocks collection changes. Durable lost-PUT reconciliation; checkpoint reset/full refresh. Existing OCS credential boundary refreshes first use/manual/reconnect/hourly. API headers case-insensitive on errors/streams; API/application evidence separate, conservative DELETE gate. Current-record merging and account/epoch fences preserve checkpoint/capability races. | +| Persistence/localization | Schema **3** initial transactional older-client fence (advanced to **4** by the review corrections below); exact note/outbox/attempt/attachment preservation, rollback and newer-schema rejection. Editor provenance and Local History/session boundaries preserved. All 23 ARB catalogs updated; outputs generated, not edited manually. UI uses existing BusyMark/Yaru components. | + +Routes remain **v1** for notes/settings and **v1.4** for attachments. Supported advertisement remains major **1**, minor **≥4**. Notes 6.0.2 tagged behavior advertises 1.4 despite documentation's 6.1 introduction wording; preserve tested 6.0 upload/download and verified 6.1 attachment DELETE gate. Attachment responses have no assumed validators. No WebDAV/Files/provider framework or Milestone 2 features added. + +## Regression coverage + +Initial collection/path regressions failed on the baseline (2 passed, 5 failed), retained in the private iteration log. New tests are enabled; strict protocol fixtures assert routes, authorization, conditional ETags and payloads. + +- `notes_milestone_test`: collection 404 clean/edit/draft/restart/restoration; Unicode/percent/space/#/parentheses/escape-like filename boundaries; encoded traversal/malformed URI; version headers on settings errors and streamed media. +- `notes_milestone_repository_test`: permanent rejection/edit recovery; 429 date/long deadline/restart; poisoned server records, positive draft evidence, ambiguous history, genuine absence; local timestamp/no-op/delay/newer acknowledgment/restart; metadata remote category/divergent same field/convergence/permission/removal; unchanged-ETag open-context media replacement/failure retention/cleanup/concurrency/deletion/removal/disposal; attachment-only 404/rejected creation endpoint; durable 429/503 download deadlines and cached-byte retention; upload recorded before injected local transaction failure/exactly once after restart; settings partial/normalization/blocker/checkpoint/lost response/restart; capability freshness/deletion gate/checkpoint/unsupported/malformed/removal; v2 fixture preservation/migration rollback/v4 fence. +- `notes_sync_coordinator_test`: controlled clocks/timers, idle/focus/visible-hidden/disposal, awaitable manual/coalesced save follow-up, six exhausted lock retries unaffected by healthy reads, fresh work without releasing exhausted writes, throttling, authenticated connectivity recovery with uncertain operations blocked/removal. +- `notes_settings_test`: settings-only first connected use refreshes capabilities once and adopts a changed application version; dirty reload/Cancel; actual localized production controls, partial suffix Save and normalization. The first-use regression failed before the controller correction. Widget I/O drains before disposing its isolate in the widget's fake-async zone. +- Controller no-op properties Save preserves revision/time and starts no request. Existing uncertain creation/upload, pagination, read-only favorite payload, workspace/editor/history/media/export/UI regressions retained and run. + +## Live fixtures and acceptance + +Private credentials, app passwords, test CA/key, raw reports and upstream copies stay under `/tmp/busymark-m1`, outside Git. Two loopback containers: Nextcloud **35.0.1** / Notes **6.0.2** and **6.1.0**, both advertised **API 1.4**, separate SQLite stores/accounts. CA-signed localhost HTTPS ports 18444/18443 with hostname/certificate verification retained. Existing browser Login Flow harness passed for both; dedicated app passwords used. + +Extended `tools/nextcloud_notes_live_acceptance.dart` passed on each baseline: **19 scenario checks plus two verified version entries** in each report. Coverage includes existing CRUD/conditional conflicts/offline/restart/304/uncertain creation/attachments/version DELETE gate; server settings/custom suffix/normalization/partial updates; maintained capabilities/server check; delayed edit-time fidelity; complete Unicode/literal-percent/escape-like attachment round trips; uncertain upload preservation. Settings restored and only owned notes removed. + +Collection 404/429/nondelivery/lost successful PUT/upload responses are explicitly simulated at the transport boundary around real authenticated requests. They are distinguished from actual server behavior. Reports: `notes-6.0-final.json`, `notes-6.1-final.json`; Login reports `login-6.0.json`, `login.json` in the private live directory. + +After acceptance, both owned containers and their anonymous volumes were removed with `docker rm -fv busymark-m1-nextcloud busymark-m1-nextcloud-6-0` (exit 0), and both owned HTTPS proxies stopped. Private reports and screenshots are retained; the disposable account credentials no longer identify a running server. + +## Validation record + +| Command / environment | Result | +| --- | --- | +| `flutter pub get --enforce-lockfile` | PASS; initial and final locked runs, pins unchanged | +| `flutter gen-l10n` | PASS; all catalogs regenerated | +| `flutter test --no-pub test/src/localization_audit_test.dart` | PASS, 21 tests | +| `dart format --set-exit-if-changed .` | PASS, 560 files, zero changes | +| `flutter analyze` | PASS, no issues | +| `git diff --check` | PASS | +| `flutter test --no-pub test/src/nextcloud_notes test/src/nextcloud*_test.dart` (includes new scheduler/settings suites) | PASS, 258 tests, zero skips | +| `bash tools/test_secure_credential_policy.sh` | PASS, exit 0 | +| `bash tools/test_nextcloud_libsecret.sh` | PASS, exit 0, isolated Secret Service/keyring | +| Browser Login Flow / real Notes API | PASS on both versions; four Login Flow assertions and 19 Notes scenarios each, with application/API versions verified | +| Native Linux desktop | PASS, 24 checks on Notes 6.1.0/API 1.4. Release application, isolated X11/disposable XDG profile, actual widgets/controllers. Category/offline ordering/properties concurrency+dirty buffer/settings normalization, native focus/resume/hidden suspension, 60s idle discovery/authenticated connectivity recovery, actual Editor/Source/Reading/Split saves and same-path replacement in open preview/editor. Process uses `WEBKIT_DISABLE_DMABUF_RENDERER=1` for unavailable WebKit GBM; no runtime downgrade. | +| Normal `flutter build linux --release` and full bundled-tool suite | PASS, production `lib/main.dart` target; final complete suite **4,141 passed, 0 failed, 0 skipped**, with bundled D2/Typst, prepared spelling resources and isolated native display | +| `BUSYMARK_NATIVE_DIALOG_TEST_DISPLAY=:97 flutter test --no-pub test/src/native_writerside_dialog_source_test.dart` | PASS, 2 tests, zero skips; explicitly covers the existing isolated-display test omitted by the ordinary CI environment | +| Post-commit `flutter gen-l10n`, `flutter pub get --enforce-lockfile`, `git diff --exit-code` and clean status | PASS after implementation commit `9f99e8738805e3e75db198df57c58d8f7290a796`; zero generated/dependency/staged changes | + +Native evidence uses `tools/nextcloud_notes_desktop_acceptance.dart`; final private report `/tmp/busymark-m1/desktop7/report.json` and before/after media screenshots. Visual inspection confirms red → blue replacement in the actual Split preview and editor, with unchanged path and note ETag. Early attempts exposed harness waits/button-label ambiguity and a WebKit environment prerequisite; those attempts are failures, not acceptance passes. This records the initial implementation acceptance; the subsequent four correctness findings and their verification are recorded below. + +The final full suite uses `env BUSYMARK_D2_PATH=$PWD/build/linux/x64/release/bundle/libexec/busymark/d2 BUSYMARK_TYPST_PATH=$PWD/build/linux/x64/release/bundle/libexec/busymark/typst BUSYMARK_TEST_SPELLING_ROOT=$PWD/build/spelling-test BUSYMARK_NATIVE_DIALOG_TEST_DISPLAY=:97 flutter test --no-pub --concurrency=4`, matching the bundled-tool configuration in `.github/workflows/flutter-linux.yml` and enabling the isolated native check. Live harness invocations follow `nextcloud-notes.md` → Verification, with the dedicated CA supplied as the Dart harness's third argument. Python Requests uses `REQUESTS_CA_BUNDLE` for CA/hostname verification; the existing browser harness uses `BUSYMARK_TEST_TLS_SPKI` to pin the dedicated test certificate. Native acceptance uses the documented target, isolated display `:97` and disposable XDG directories. + +One final default-concurrency attempt finished with 4,139 passed, 1 failed, 1 skipped: the existing `first-save pre-promotion cancellation: clear all, failed pending capture` Local History test's three-second workspace-state wait timed out. The exact case rerun passed (1 test, zero skips); two preceding complete runs also passed. The final complete suite passed with bounded concurrency, preserving every test and the same bundled tools. No Local History implementation or existing test was changed to suppress this failure. + +## Correctness review follow-up — 2026-10-09 + +Baseline: branch `feature/nextcloud-notes-workspace`, HEAD `5261dcb56022259628ae3bc3d54c3b8204b8c26e`, clean working tree; no applicable ancestor/repository `AGENTS.md`. Flutter **3.47.5** / Dart **3.13.4** retained. Existing implementation and evidence read before targeted edits; no reset, dependency upgrade, unrelated refactor, or Writerside editing-button change. + +The initial correction regressions reproduced authentication recovery, reviewed metadata substitution, and cleared throttling deadlines (**3 passed, 10 failed**). The literal-Unicode regression separately reproduced `ArgumentError: Illegal percent encoding in URI` with the original decoder. Private before/after logs remain outside Git. No new test is skipped or disabled. + +| Finding | Correction and regression evidence | +| --- | --- | +| Reconnect leaves writes blocked | Verified same-server/login reconnection waits for the previous credential pass outside the mutation queue, then atomically restores durable authentication-blocked work with the current account record. Revision/acknowledgment/base/deadline remain intact. Actual conflicts and uncertain creation/upload evidence remain protected. `notes_review_corrections_test`: unchanged-ETag 401 → reconnect → successful write, both in-process and after restart; legacy persisted authentication state; in-flight old credential request plus newer edit; real divergence and uncertain bytes/wire-body preservation. | +| Local metadata alternative replaced by server state | Persist typed competing **local** metadata independently of actual server observations. The dialog labels the alternative “Previous local edit.” Previous/current/explicit merge/separate-note choices preserve precisely B/C; dirty content and unrelated accepted metadata remain independent. Fetching actual server state validates permissions/conditional publication and retains a second server conflict when necessary; it never substitutes that state for the reviewed local alternative. A changed actual server choice requires another review before applying it. Server-backed and draft workflows cover every choice, restart, synchronization, explicit current-side merge, permission changes, and second server divergence. Eight production widget cases open the actual B/C menu and apply every action through the controller. | +| Editing bypasses Retry-After | Content and metadata mutations retain the durable not-before deadline while accepting edits. Deadline-only work with a reset edit retry count remains eligible for a coordinator timer; no timer sends before the deadline. Content/metadata/restart/exact-expiry repository cases and hidden-workspace/restart controlled-timer coverage verify publication of the accepted newer revision. Existing exhausted budgets and uncertainty tests remain enabled. | +| Literal Unicode URI destination throws | Escape literal non-ASCII characters before exactly one URI decode, then validate the untouched raw segments. Do not use `Uri.path`, which normalizes traversal before validation. Literal and encoded café HTML destinations coalesce to one canonical download; literal percent filenames, percent-like escapes, malformed encodings, authorities, backslashes, and encoded traversal remain covered. Existing filename upload → Markdown → download round trips remain passing. | + +Schema **4** fences the new durable local-conflict evidence from older clients. Transactional migrations from schemas **2 and 3** preserve exact outbox data and attachment bytes, roll back the version on malformed recovery, and reject schema **5**. Exact creation-attempt evidence, IDs, history/editor provenance, account identity, and server checkpoints are retained. + +Fresh owned fixtures: Nextcloud **35.0.1** with Notes **6.0.2** and **6.1.0**, each advertising **API 1.4**. Existing Login Flow harness passed on both with a dedicated CA/hostname verification and browser leaf-key pin. Extended Notes harness passed **23 scenario checks plus 2 version entries per baseline** (`notes-review-6.0.json`, `notes-review-6.1.json`). Authentication rejection is a **real 401**, obtained by supplying intentionally invalid credentials to one real PUT; successful same-account recovery uses the authorized Login Flow credential. Throttling is explicitly simulated at the transport boundary around real publication, with edits/restart/deadline expiration. Every metadata choice is checked against an actual subsequent server GET. Literal Unicode HTML upload/publication/download is exercised on both baselines; existing uncertain-outcome and attachment-deletion gates remain covered. + +Extended native Linux release acceptance passed **31 checks** on Notes **6.1.0/API 1.4**, disposable XDG profile and isolated display `:97`. It drives the actual stale properties dialog and B/C conflict action for server-backed/draft notes, real 401/reconnection, injected 429 plus edits/deadline expiry, and literal Unicode HTML images through Editor/Source/Reading/Split. Actual displayed media changes red → blue at the same path/unchanged note ETag. Private report/screenshots: `/tmp/busymark-m1/desktop-review/`. Settings restored; only test-owned notes were removed, both owned review containers/volumes removed (exit 0), and their HTTPS proxies stopped. Earlier widget iterations exposed asynchronous modal initialization/disposal fixture issues; final fixtures await real SQLite/controller completion and dispose their own listeners. These iterations are not acceptance passes. + +| Final follow-up gate | Result | +| --- | --- | +| Locked dependencies; regenerated localizations | PASS, `flutter pub get --enforce-lockfile`; `flutter gen-l10n`, no pin changes | +| Localization audit | PASS, **21 tests** | +| Formatting; analysis; whitespace | PASS, `dart format --set-exit-if-changed .` (**561 files, 0 changes**); `flutter analyze --no-pub` (no issues); `git diff --check` | +| All affected Nextcloud suites | PASS, **289 tests, 0 failed, 0 skipped**; includes 21 correction workflow tests, 8 production dialog cases, controlled deadline timer, and schema 3 migration | +| Secure credential policy / libsecret | PASS, both required shell scripts exit **0** | +| Linux builds | PASS, extended desktop acceptance release target, then normal production `flutter build linux --release --no-pub` | +| Complete Flutter suite with bundled tools/native display | PASS, **4,172 tests, 0 failed, 0 skipped**, exit **0** | +| Post-commit generation/locked resolution and clean tree | PASS after correction commit `1ece41e03cec4ba510136a384ff1e41218074d0a`: `flutter gen-l10n`, `flutter pub get --enforce-lockfile`, both Git diffs and status clean | + +The full-suite command remains the bundled D2/Typst/spelling/native-display command recorded above with `--concurrency=4`. All four corrections are implemented and verified by affected/full suites and live/native acceptance. Post-commit generation and locked resolution also leave the tree clean. No remaining implementation or acceptance blocker. + +## 2026-10-10 — metadata review during an older write + +Continuation baseline: branch `feature/nextcloud-notes-workspace`, HEAD `e5725cc1581a73de6f73a8aeb4744b48d0ce72b3`, clean tree; Flutter **3.47.5**, Dart **3.13.4**. The prior four corrections and unrelated Writerside implementation are retained. + +An unresolved typed `metadataConflict` now independently blocks note/attachment publication and automatic retry eligibility, including inside serialized write preparation. It also keeps the note pending for persistence/list reconciliation regardless of a legacy transient state or incorrect acknowledgment. An older successful response acknowledges **only its captured revision** and updates the actual server base/ETag without normalizing either reviewed metadata alternative. Retryable errors preserve the review state/message and retain their failure/deadline evidence. Explicit uncertain-creation adoption and staging publication preserve outstanding metadata review; resolving B/C does not release an uncertain POST. Error reconciliation does not request a server note for a local-only draft. Sidebar/editor status gives durable review evidence precedence over transient/synchronized labels while retaining separate authentication, permission, and uncertain-creation states. No schema or protocol changes are needed. + +`notes_metadata_race_test.dart` holds PUT/POST replies for **200, 429, and 503**, tests both B/C choices, subsequent synchronization, deadline expiration, restart, captured acknowledgment/base, and exact uncertain-request evidence. Two additional persisted-state cases exercise the independent guard even with `ackRevision == revision`; two production widget cases check restored conflict status/Compare. Before correction, the held-request matrix had **2 passed, 10 failed**, including a successful POST that immediately published C without review (`race-before3.log`). Final focused repository coverage: **14 passed**; final affected suite: **305 passed, 0 failed, 0 skipped**. + +Fresh disposable fixtures verified Nextcloud **35.0.1**, Notes **6.0.2** and **6.1.0**, API **1.4**. Browser Login Flow passed on both with the dedicated CA/hostname verification and existing leaf-key pin. Extended Notes acceptance passed **25 scenarios plus 2 version entries per baseline** (`notes-race-6.0.json`, `notes-race-6.1.json`). Held 200 replies execute real HTTPS writes; 429 is an injected rejection without executing the write; 503 is injected **after a real successful write**, preserving uncertain POST resolution as a separate explicit decision. Both reviewed alternatives are checked against real subsequent server GETs after restart. The existing attachment-deletion version gate remains verified. + +Native Linux acceptance passed **47 checks** on Notes **6.1.0/API 1.4**, with a disposable profile/display `:97`. Eight held PUT/POST UI workflows cover success/retryable failure and both “Use previous edit”/“Keep Mine” choices; each checks blocked publication, actual conflict UI, and the selected server value. Existing connectivity/focus/idle discovery, settings, and Editor/Source/Reading/Split media flows also pass. Private report/screenshots: `/tmp/busymark-m1/desktop-race/`; the held POST review screenshot was inspected. Settings restored, only test-owned resources cleaned up, both owned containers/volumes removed (exit **0**), HTTPS proxies stopped. + +| Final race-correction gate | Result | +| --- | --- | +| Locked dependencies; generated localizations | PASS, `flutter pub get --enforce-lockfile`; `flutter gen-l10n`, unchanged pins/catalogs | +| Localization audit | PASS, **21 tests** | +| Format; analysis; whitespace | PASS, **562 files, 0 changes**; `flutter analyze --no-pub`, no issues; `git diff --check` | +| All affected Nextcloud suites | PASS, **305 tests, 0 failed, 0 skipped**, final error-boundary/captured-base assertions included | +| Secure credential policy / libsecret | PASS, both required scripts exit **0** | +| Linux builds | PASS, extended native acceptance target, then final normal production `flutter build linux --release --no-pub` | +| Complete Flutter suite, all 199 files | PASS in two groups: **50** Local History store tests (`--concurrency=1`) + **4,138** tests in the other 198 files (`--concurrency=4`), **4,188 passed, 0 failed, 0 skipped** total; same bundled D2/Typst/spelling/native-display configuration | +| Post-commit generation/locked resolution | PASS after implementation commit `50bd22706440976a5e60c298f50950a52b5c2a15`: `flutter gen-l10n`, `flutter pub get --enforce-lockfile`, both Git diffs and status clean | + +Single-invocation validation limitation: both full runs (`--concurrency=4`, then `2`) finished with **4,187 passed, 1 failed, 0 skipped** each. Unchanged `real process contention waits and captures without a warning` and `lock timeout closes acquisition and queues recover without late capture` respectively exceeded the History child's 60s readiness bound. The first exact case passed in isolation (**1 test**), and the entire unchanged Local History store suite passed separately (**50 tests**). The final direct Flutter invocation uses an explicit manifest from `rg --files test -g '*_test.dart'`, covering every other test file exactly once; all **199 files** are accounted for. No application/test change suppresses these timeouts. The initial dynamic launcher's automatic approval review timed out without executing; its permitted direct-command retry succeeded. Requested metadata-race workflows are verified; the single-invocation runner limitation remains recorded. diff --git a/docs/development/nextcloud-notes-milestone-2.md b/docs/development/nextcloud-notes-milestone-2.md new file mode 100644 index 00000000..c9a708ef --- /dev/null +++ b/docs/development/nextcloud-notes-milestone-2.md @@ -0,0 +1,322 @@ +# Nextcloud Notes Milestone 2 + +Baseline: `7b44bc2a54542c7741763e21e8a75f0a96d02cc1`, clean working tree, +`feature/nextcloud-notes-workspace`. No applicable `AGENTS.md`. Milestone 1 +invariants and verification environment read; no reset or repeat audit. + +Contracts: [Notes v6.1.0 API](https://raw.githubusercontent.com/nextcloud/notes/v6.1.0/docs/api/v1.md) +and [SQLite FTS5](https://sqlite.org/fts5.html) read before changes. Categories +remain slash-separated attribute values; read-only notes allow favorite-only +changes. Attachment filenames retain the established decode-once boundary. + +This record lists implementation decisions and actual acceptance results. +Private fixtures, credentials, CA, profiles, screenshots and raw logs remain in +`/tmp/busymark-m2`. Initial ordinary Flutter invocation could not write the SDK +cache; required Flutter commands use the managed escalation boundary. + +## Implementation decisions and locations + +Module paths below are under `lib/src/nextcloud_notes/` unless qualified. + +- Schema **5**, transactional migration from the reviewed schema 4, in + `data/notes_store.dart`: FTS5 trigram index plus an ordinary indexed identity/ + revision mapping, account-scoped offline requirements/cancellation and import + associations. Existing note JSON, conflicts, exact creation evidence, outbox, + attachments and settings are retained. Index writes accompany every durable + note write; bounded 32-note rebuilding yields the worker between requests. + Missing derived indexes rebuild; newer schemas are rejected. Migration tests + remove the new tables to exercise real old-schema creation and rollback. +- `application/notes_navigation.dart` and existing `presentation/notes_sidebar.dart`: + descendant counts, destinations, deterministic activity/title ordering, stable + local-ID selection/focus/open states, reviewed context targets and batch actions. + Category and note rows are rendered through bounded scrolling viewports. + `notes_repository.dart` applies field-aware patches inside its existing mutation + boundary; `workspace_controller.dart` saves dirty target buffers first. Partial + outcomes preserve successes. Read-only writes retain favorite-only payloads. +- `domain/notes_search.dart`, `application/notes_search_controller.dart`, + `presentation/notes_workspace_ui.dart`: literal AND terms, phrases, title/ + category restrictions, Unicode whole-word verification and partial/short terms. + NFC/lowercase trigrams narrow candidates; exact literal verification supplies + original UTF-16 offsets, snippets and content digests. Dirty-buffer overlays run + outside SQLite in an isolate. Bounded transfers, request generations, indexing, + errors and Show more are explicit. Metadata-only matches highlight their own + text without fabricating content offsets. Navigation checks revisions/digests, + relocates changed occurrences and offers an explicit source action. Both Notes + search entry points use this workflow; local search/replacement is preserved. +- `app/quick_open.dart`, existing command/shortcut/menu registries: Ctrl+P, + deterministic exact/prefix/title/path ranking, keyboard selection/dismissal and + focus restoration. Local paths and remote local IDs remain distinct. Unicode + normalization is cached per candidate before sorting, following the real UI + performance finding. Ctrl+Shift+P remains the command palette. +- `application/notes_offline_controller.dart`: separate note/category requirements, + reconciliation after membership/reference changes, current-revision inspection, + progress/missing/external/failure details and persistent cancellation. It invokes + the existing repository resolver, preserving credentials, throttling, size and + ownership checks, freshness invalidation and account-removal fences. Failed + downloads wait for deliberate retry. Retained SQLite bytes survive cache-file + cleanup/restart; removing requirements removes no document or sole upload copy. +- Recovery uses existing deleted records and Local History, account-scoped previews, + actual supported-media availability and existing recover-as-new paths. Deletion + conflicts remain in the ordinary list. No server trash/history is claimed. +- `application/notes_transfer_service.dart`, existing Markdown copy exporter and + AtomicFileWriter: immutable saved local snapshots with attachment-generation/account fences, category directories, + version-1 metadata manifest, collision/Unicode/path-safe names, cached recognized + media, explicit omissions, staged publication/cancellation and bounded media + copying. Reviewed imports validate source paths/symlinks/versions/digests, allow + selection/category correction and create distinct identities. Note/outbox/media/ + source-item association commit together; restart/resume cannot duplicate a + successfully imported item. Existing uncertain publication rules still apply. + The pure media resolver was separated from its Flutter scope so the existing + Dart live harness can reuse the same exporter/service. +- 29 new messages are translated in all 23 ARB catalogs; generated localization + output is regenerated. Existing Writerside editing-button code and dependency + pins are unchanged. User instructions and manifest format are documented in + `nextcloud-notes.md`, the README and `docs/search-and-replace.md`. + +## Acceptance evidence collected + +Private reports/screenshots/logs are under `/tmp/busymark-m2`; no credential, +certificate, profile, raw report or test database is committed. + +| Check | Actual result | +| --- | --- | +| Initial locked dependency resolution; localization generation | PASS | +| Localization audit | PASS, 21 tests; later combined localized UI run 53 passed | +| Focused Notes/controller/widget/command regression run | PASS, 270 tests, including metadata race, review corrections and synchronization coordinator suites | +| New deterministic M2 storage/repository/controller cases | PASS, 30 cases including original-offset metadata highlights, batch conflict blocking after held PUT/restart, export media fencing and account-removal publication cancellation | +| Local History store exhaustive group | PASS, 50 tests, complete file separately | +| Previously failing spelling/Local History workspace files | PASS, 285 tests, unchanged assertions | +| Secure credential policy and libsecret scripts | PASS, both scripts exited 0 | +| HTTPS browser Login Flow | PASS on Notes 6.0.2 and 6.1.0, disposable Nextcloud 35.0.1 fixtures, API 1.4, dedicated CA/hostname verification and browser certificate pin | +| Extended Dart live acceptance | PASS, final snapshot-fence rerun: 28 scenarios + 2 version entries on each baseline; includes batch/index/offline snapshot/recovery/resumable import and real subsequent HTTP synchronization | +| Native complete everyday journey | PASS on each baseline in two actual processes: 3 checks before offline shutdown + 13 after offline restart, proving pending identity/text/media and zero publication before continuing Quick Open/search → batch → reconnect/sync → category retention → disconnect/media → delete/recover → export/import | +| Separate actual offline process restart | PASS, final actual-process run after fixture removal: 14 checks, persisted account/category bytes, all four views, indexed search, Local History, actual OS Ctrl+P/Enter/Escape and Ctrl+Shift+P, local Markdown/Writerside Quick Open and native Ctrl+F search | +| Screenshots inspected | Populated sidebar, Quick Open, contextual search/highlights, batch targets, offline completeness, Recovery preview, export result, import collision/media review; corrected hidden snippet matches, compact row hit targets and misleading text/sync labels | + +Tests cover nested/uncategorized/recovery inclusion, sorting and stable selection, including real Ctrl-toggle/Shift-range/Ctrl+A widget events and reviewed context targets surviving a concurrent reorder, +mixed permissions, concurrent unrelated/same-field metadata changes, offline +batch/restart; unopened/pending/dirty search, phrases/filters/whole words, short +Unicode/combining/technical terms and literal FTS operators, cancellation/paging, +stale relocation/index rebuild/deletion/account removal; independent retention, +new references, failures/manual retry/persistent cancellation/late account removal +and pending sole copies; deleted drafts/retained media/new-identity recovery; +nested duplicate-title/favorite/Unicode/percent Markdown+HTML media round trips, +unsafe versions/paths/symlinks/changed files, omissions, cancelled and failed +filesystem publication, partial import resume and transactional rollback; changed attachment generations or account removal prevent export publication without blocking unrelated local text edits. + +## Reproducible 10,000-note performance + +`tools/nextcloud_notes_performance.dart` is compiled with `dart build cli` and run +as a native product/AOT executable using the project's SQLite native build hook. +Corpus: 10,000 real durable notes, 1,279 category values including Uncategorized, +250 duplicate title values, **21,785,692 UTF-8 content bytes**, 2,000 cached +attachments / 4,096,000 bytes. Content includes Latin accents/decomposed combining +marks, Chinese, Japanese, Russian, Arabic, Korean, Greek and technical identifiers. +Reproduce with `dart build cli -t tools/nextcloud_notes_performance.dart -o /tmp/busymark-perf-build`, then `/tmp/busymark-perf-build/bundle/bin/nextcloud_notes_performance /tmp/busymark-perf-corpus` (use an empty corpus directory). +It performs a fresh rebuild, ten warm queries each, saves during rebuild/query, +and twenty durable imports during search. The database is 117,727,232 bytes. + +Machine: Linux x64 7.0.0-34, Intel i9-9900K at 3.60 GHz (8 cores / 16 logical CPUs), 50,303,365,120 RAM bytes; Dart 3.13.4, +Flutter 3.47.5, actual SQLite **3.53.4** with FTS5 trigram support. All dependency +pins remain locked. Measured native report `performance-third/report.json`: + +| Measurement | Actual | +| --- | --- | +| Corpus creation with maintained index | 39.862 s | +| Fresh initial index rebuild | 21.825 s | +| `token42` median / p95 | 48.747 / 52.049 ms | +| `"alpha beta" category:"Area 7"` median / p95 | 49.311 / 64.661 ms | +| `中文` median / p95 | 53.954 / 62.059 ms | +| `C++` median / p95 | 48.100 / 66.695 ms | +| `é` median / p95 | 44.573 / 55.456 ms | +| Durable saves during rebuild median / p95 / maximum | 41.745 / 170.007 / 592.668 ms | +| Durable save during query | 42.985 ms | +| Durable import during query median / p95 | 19.798 / 75.712 ms | +| RSS before / after repository loaded | 14,213,120 / 181,030,912 bytes | +| Reported maximum RSS | 180,297,728 bytes (OS sample counter) | + +The packaged Linux UI also opened this real corpus plus the 20 imported notes, +rendered sidebar/search/Quick Open and saved while searching. Its first run +measured Quick Open 5,644 ms and a concurrent durable save 132 ms, RSS 617 MB / +maximum 630 MB, exposing repeated normalization in the ranking comparator. +That finding caused the candidate-normalization correction; final UI measurements +are: Quick Open **228 ms**, concurrent durable save **111 ms**, RSS 608555008 bytes / maximum 629751808 bytes, from `ui-corpus/ui-performance.json`. This is the actual Linux release application with the same real SQLite library, using Impeller OpenGLES with `LIBGL_ALWAYS_SOFTWARE=1` on isolated Xvfb `:97`; timing polls are 100 ms. It is not a mocked widget list or a hardware-GPU measurement. The service benchmark's bounded worker execution remained +responsive, but its worst rebuild save exceeded half a second under concurrent +build/test load; the measured maximum is retained here rather than concealed. + +## Failed attempts and corrections + +- Initial M2 tests exposed an outbox SQL typo and an isolate closure capturing the + store; corrected and all new cases rerun. Dart live harness first failed to + compile through a Flutter media-scope import; pure resolver extraction fixed it. +- Legacy widget selectors referred to the old filtering controls/properties + button. Updated them to actual new destinations/search/menu and drained real + SQLite work outside fake time, retaining assertions. A compact category row + later missed pointer hit testing; explicit dimensions and a settled scroll fixed + it. The exact category pointer scenario passed afterward. +- First native run used a stale fixture ETag for a simulated external update; + fixture now gets its current ETag. Other attempts exposed uncertain creation + during simulated transport loss, unavailable inline media when adjacent raw + HTML shared its paragraph, and the fixture expecting `local.md` while Quick + Open displays the extension-free title `local`. Corrected the journey to create + online before offline edits, separate image blocks, and match the actual title. + One capabilities request timed out during concurrent fixture/load work; the + verified endpoint subsequently responded. Interrupted early native runs are + retained in private logs; they are not represented as successful journeys. +- A concurrent focused run timed out the 30-second mixed-batch test during broad + compilation/test load; all 26 cases subsequently passed alone in 4 seconds. +- First exhaustive remaining-file attempt was interrupted after spelling install + and Local History workspace timing failures, plus cancellation/finalization + errors. It is not a clean exhaustive run. A second four-worker attempt again hit the unchanged spelling-install timing failure and was interrupted; the final remaining-file group uses one worker. Both unchanged affected files passed + together afterward (285 tests). A one-worker exhaustive attempt reached the source audits and exposed two implementation violations: Material icons outside BusyMark's glyph catalog and the old blanket Ctrl+P prohibition. Both controls now use the catalog, and the audit verifies the newly authorized Quick Open binding while retaining all no-print checks. That attempt completed with **4,168 passed, 2 failed, 0 skipped**. The corrected audit passed all 49 cases. Final exhaustive groups and exact totals follow. + +- A final 6.0.2 live attempt stopped after 27 checks at an organization-sync assertion; the unchanged workflow rerun passed all 30. The assertion now reports state/error/deadline on failure; the transient cause was not established. +- The added pointer/keyboard production-widget test initially named a nonexistent batch widget; corrected to the existing BusyMark dialog shell. Its targeted run passed, then it entered the exhaustive current-file run. +- Export review identified a same-path media refresh race. Captured snapshots now carry attachment generations and account fences; refreshed bytes or account removal abort staged publication. Two new deterministic cases passed; analysis and all source audits pass. +- The separate restart harness initially required absent `xdotool`; a test-only standard-library X11/XTest helper now sends actual Ctrl+F events without adding an application dependency. The restart run passed all 11 checks. Actual OS Ctrl+P/Enter/Escape and Ctrl+Shift+P checks were then added; their first attempt exposed the harness's assumption that Quick Open had an explicit text controller. It now enters text through the rendered EditableText controller. The next attempt passed Ctrl+P/Enter/Escape but expected the wrong capitalization for the existing "Command Palette" title; corrected to the catalog label. The final native restart passed all **14** checks after the fixtures were removed. + +- Directly invoking the non-executable credential scripts returned shell status 126; the documented Bash invocations both passed (exit 0), without permission/file changes. + +- Before handoff, the prior native journey was tightened: its fresh-store reopen occurred before search, while its actual process restart occurred after synchronization. The final harness now supports `--stop-offline-m2` / `--resume-offline-m2`, retains only test-owned fixture IDs between processes, and stays offline through first-process shutdown. Both baselines passed the exact requested sequence (3 + 13 checks each), with pending text, stable identity and bytes asserted before publication. A draft assertion incorrectly treated the availability record as an object; corrected to its existing required/available/external fields. No production code changed. + +## Final verification and completion + +Implementation commits: `0ce81ca`, `860f18f`; production-widget selection regression: `383a403`. + +The final exhaustive verification uses the documented bundled D2/Typst/spelling setup and native display `:97`, with `--no-pub --concurrency=1`. The complete Local History store file passed **50 tests**, exit 0 (`full-history-verified.log`). The other group uses all **200** paths from `remaining-test-files.txt` exactly once; the manifest SHA-256 is `623f4e141e1a877c18aee1a4a906c61d8a01c4e40521a66d893756eda36c622a`. A fresh filesystem comparison accounts for all **201** current files with no omissions/duplicates. That group passed **4,173 tests**, exit 0 (`full-remaining-verified.log`). Combined: **4,223 passed, 0 failed, 0 skipped**, in the established **two-group** approach. The new export-fence and production multiselection cases appear in the final log. This is exhaustive split verification; the single-invocation runner limitation remains documented in Milestone 1. + +Both credential probes passed through Bash (exit 0). Final analysis found no issues; formatting checked **568 files, 0 changes**. The normal production release passed, exit 0 (`production-release-segmented-final.log`), restoring the production entry point after native acceptance. Locked dependency resolution and localization regeneration passed after implementation, with unchanged pins/generated output; the fresh localization audit passed **21** tests. Handoff repeats both SDK commands after the evidence commit and checks Git status. + +Only test-owned fixtures were removed: both `busymark-m2-6-0` / `busymark-m2-6-1` containers and anonymous volumes, and the two proxies after verifying their PID/command/port. Cleanup exited 0. Private CA, credential/profile/report/corpus artifacts remain outside Git. The final shortcut restart test ran after the first fixture removal. Fresh isolated fixtures were then recreated for the exact two-process journey; browser Login Flow passed on both again, and the same owned cleanup was repeated afterward. + +All five Milestone 2 areas are implemented, connected to the production UI, and verified together. No unresolved implementation or feature-acceptance blocker; no Milestone 3 work. Implementation and evidence are committed locally without merge, push, or publication. + +## Correctness follow-up — 2026-10-10 + +Baseline: `6a14289d54978ffe699f0180ad426f7ab703e598`, clean working tree, +`feature/nextcloud-notes-workspace`; no applicable `AGENTS.md`. Accepted M1/M2 +work and dependency pins retained. No Writerside editing-button changes. +The five reported defects were confirmed before fixes. New regression run: +**1 passed, 8 failed**; the offline assertion first required the scanner's Unicode +encoding adjustment, then separately failed on recovery-only bytes being counted +as available. Private before/after logs and rebuilt fixture artifacts live in +`/tmp/busymark-m2-corrections` (the old `/tmp/busymark-m1` and `m2` artifacts +were absent). Upstream pinned API and FTS5 contracts consulted again. + +| Correction | Implementation and regression coverage | +| --- | --- | +| Complete search continuation | Lazy literal per-term occurrence streams merge by start/end, with bounded grapheme-boundary maps. The SQLite worker returns a cursor only past fully processed documents, plus revision/content/metadata-fenced partial-document continuation. The 200-hit transfer bound remains; controller requests continue across it. Dirty overlays have a global transfer bound. Tests compare exact identities/UTF-16 offsets for 40 occurrences, 10 × 32 matches at limits 80/160/240/320/400, 962 Unicode occurrences across 40 candidates, overlapping terms, dirty text, and changed revisions. Production dialog verifies all 320 keys and Show more. Changed-document navigation pages to the nearest current occurrence in an isolate, then rechecks the current buffer before selecting. | +| New imports versus resumed operations | Every new review gets a new operation UUID. Schema **6** adds account-scoped durable reviewed operations; existing item associations remain intact. Item-to-note association is still atomic with note/outbox/index/media creation, now namespaced by operation. Restart reconstructs selection, reviewed categories and media fingerprints; completed items are accurately labeled and cannot be retargeted in resume review. Production chooser explicitly offers a new import or a specific interrupted operation. Completed jobs are omitted from resume choices. Tests cover new same-source imports, category corrections, repaired/changed media, partial resume from SQLite, account removal, and unchanged uncertain create/upload evidence with no repeated publication. | +| Safe relative local media | Transfer service has a separate decode-once local destination parser; resolve dot segments relative to the document before enforcing selected-root containment and symlink restrictions. Remote attachment validation is unchanged. Markdown and supported HTML tests cover `./`, inside-root `../`, Unicode/literal-percent filenames, outside-root escape, restart and synchronization. | +| Live offline eligibility | Repository availability selects attachments through the same `_liveMediaAttachment` rules as ordinary media resolution. Deleted bytes remain retained and separately usable for recovery; they neither count as live availability nor trigger automatic resurrection. Tests cover retained references, offline requirements/restart, unchanged tombstones/bytes, and a legitimate later upload superseding the tombstone. | +| Recovery search | Sidebar passes an explicit Recovery scope; bounded store queries scan existing account-scoped retained deletion records using the same literal semantics. Deleted records remain outside live FTS search. Results open the existing Recovery preview, with history selector and byte checks, preserving the current document. Tests cover title/category/content, account boundaries, deletion conflicts, restart, account removal, and new-identity recovery/publication. Production Enter navigation checks preview identity and absence of deleted editor buffers. | + +Migration tests cover schemas 2–5, rollback and rejection of schema 7; a genuine +schema-5 fixture preserves accounts, notes, exact outbox, blobs, offline settings +and the legacy import ledger byte-for-byte. All new UI strings are translated in +23 catalogs and generated output is regenerated. + +Follow-up verification artifacts are private under `/tmp/busymark-m2-corrections`. +The affected suite passed **376 tests**, including all accepted metadata-race, +review-correction, coordinator, search/navigation, export, Local History workspace +and localization cases (`affected-verified.log`). Both credential-policy and +libsecret scripts passed through Bash, exit 0. The complete Local History store +group passed **50 tests**, exit 0 (`full-history.log`). The remaining exhaustive +group passed **4,195 tests**, exit 0 (`full-remaining.log`), with all **200** +current paths exactly once, one worker, the +bundled D2/Typst/spelling resources and native display `:97`. Its manifest has the +same SHA-256 `623f4e141e1a877c18aee1a4a906c61d8a01c4e40521a66d893756eda36c622a`; +a fresh comparison accounts for all **201** files without omissions/duplicates. +Combined: **4,245 passed, 0 failed, 0 skipped**. All 200 distinct loading entries +are present in the final log. This is exhaustive **two-group** verification, +preserving the accepted single-invocation History child-startup limitation; +there was no failed exhaustive attempt during this follow-up. +Native Hunspell package tests also passed **13** cases, exit 0. +Locked dependency resolution, localization regeneration and the **21-test** +localization audit passed. Formatting checked **573 files, 0 changes**; final +analysis found no issues, and `git diff --check` passed. Dependency pins are +unchanged. The final normal production Linux release passed, exit 0 +(`production-release-verified.log`), restoring `lib/main.dart` after native tests. + +Fresh disposable HTTPS fixtures use Nextcloud **35.0.1**, Notes **6.0.2** / +**6.1.0**, API **1.4**. Browser Login Flow and extended live Notes acceptance +passed on each: **34 scenarios plus 2 version entries**, exit 0 (`live/notes-*.json`). +The corrections execute real synchronization after restart, check exact occurrence +sets, distinct import identities/reviewed categories/repaired media, partial +operation resume, deleted-media eligibility/replacement, and Recovery-only search +followed by new-note publication. The 6.0.2 attachment-delete version gate stays +intact. CA/hostname verification remains enabled; credentials and reports stay +outside Git. Xvfb was obtained as a private unpacked package because the machine +does not have it installed; no system package or project dependency changed. + +Both strengthened native Linux journeys passed in fresh private profiles: +**16 checks before process shutdown + 35 after actual restart**, per baseline, +exit 0 (`desktop-6.0.2-views-verified` / `desktop-6.1.0-views-verified`, separate +`report-corrections-start.json` / `report-corrections-resume.json`). They remain +offline across shutdown/restart, exhaust 40 and 320 occurrences through the +production search dialog, review a distinct import/category/repaired relative +media, resume a persisted partial operation without duplicate notes, retain +deleted-media bytes while showing incomplete offline status, replace media, +search Recovery and open its preview, recover a distinct identity, then perform +real synchronization. The existing full everyday journey follows: nested +category creation, offline edits/media, Quick Open/search, batch organization, +category retention, all four views, deletion/recovery and export/import. +Inspected screenshots cover populated sidebar, pagination/highlights, new/resume +review, specific offline omissions, scoped Recovery search/preview, actual +Source/Reading/Split media, batch review and portable transfer. Final screenshots +have correct route dismissal, readable controls and no obstructing leftover +search modal. Unsupported raw HTML remains visible through the existing source +representation; recognized imported Markdown media is rendered in supported views. + +The unchanged reproducible **10,000-note native AOT** benchmark was rerun during +concurrent test/desktop load (`performance/report.json`). Corpus: **1,279** +categories, **250** duplicate titles, **21,785,692** UTF-8 text bytes, **2,000** +attachments / **4,096,000** bytes. Intel i9-9900K, 16 logical processors, Linux +7.0.0-38-generic, Flutter **3.47.5** / Dart **3.13.4**, packaged SQLite **3.53.4**. + +| Follow-up measurement | Actual | +| --- | --- | +| Corpus creation with maintained index / fresh rebuild | 38.404 / 26.935 s | +| `token42` median / p95 | 59.511 / 69.418 ms | +| `"alpha beta" category:"Area 7"` median / p95 | 54.409 / 56.554 ms | +| `中文` median / p95 | 76.295 / 121.886 ms | +| `C++` median / p95 | 68.318 / 90.540 ms | +| `é` median / p95 | 55.533 / 64.104 ms | +| Saves during rebuild median / p95 / maximum | 55.506 / 166.747 / 444.959 ms | +| Save during query | 84.987 ms | +| Import during search median / p95 | 32.981 / 106.349 ms | +| RSS before / after repository loaded | 13,873,152 / 186,404,864 bytes | +| OS maximum RSS sample / database size | 185,470,976 / 118,743,040 bytes | + +Failed attempts are retained rather than counted as passes. Initial regressions +are described above. New widget selectors first counted both a title and its +highlighted snippet; corrected to result rows. Expanded regressions initially +used two nonexistent helper properties, then passed after correcting those +test references. An initial concurrent affected invocation named a nonexistent +shortcut-test file and hit the unchanged unsaved-refresh timing assertion; the +correct current command-registry suite and exhaustive affected set passed with +one worker. Fixture provisioning first ran before image initialization; it was +recreated only after readiness. Browser TLS exposed missing CA key-usage +extensions, corrected by reissuing the dedicated CA with the same key and strict +extensions. Native attempts exposed insufficient route-close waiting and an +offline resume GET-settings in the harness; both harness errors were corrected. +Screenshot inspection then exposed a harness selecting the sidebar recovery row +instead of the search-result row, and changing only the global view preference. +The harness now targets exact search-result keys, verifies route dismissal, and +switches the active buffer view while asserting rendered media/source widgets. +Its first strengthened build caught incorrect existing property names; those +harness-only references were corrected before rerunning both desktop baselines. +A rerun also reused an earlier private output directory containing two fixture +accounts; its failed resume was stopped, and both final journeys used previously +unused `*-views-verified` directories. No production workaround or weakened +assertion was introduced for these harness failures. + +Implementation commit: **`4609a77`**. Only the two ownership-labeled +`busymark-m2c-6-0-2` / `busymark-m2c-6-1-0` containers and anonymous volumes, +the verified test proxy and private Xvfb `:97` were removed; cleanup exited 0. +Private profiles, CA/credentials, reports and corpus remain outside Git. +Post-implementation locked resolution and localization regeneration leave pins +and generated files unchanged. Handoff repeats both after the evidence commit +and confirms Git status. All five defects are resolved and verified; no remaining +correction blocker, unrelated Writerside change or Milestone 3 work. Commits stay +local without merge, push or publication. diff --git a/docs/development/nextcloud-notes-milestone-3.md b/docs/development/nextcloud-notes-milestone-3.md new file mode 100644 index 00000000..83f40e19 --- /dev/null +++ b/docs/development/nextcloud-notes-milestone-3.md @@ -0,0 +1,326 @@ +# Nextcloud Notes Milestone 3 + +Baseline: `feature/nextcloud-notes-workspace`, HEAD +`9002549b024f0cbd072115e4198d74a216c16093`, clean working tree. No applicable ancestor +or repository `AGENTS.md`. Accepted Milestone 1 and 2 records read; existing schema +**6**, pins and synchronization invariants retained. + +Toolchain: Flutter **3.47.5** (`6a19cca564`), Dart **3.13.4**, Docker **29.8.2**, Node +**24.15.0**, Python **3.13.5**. Flutter needs managed SDK-cache write access. Snapcraft +is initially absent. README clean Snap procedure, confinement contract and the Linux +workflow Snap job read. No scaffold repack is acceptance evidence. + +Required upstream copies fetched once with HTTPS verification into +`/tmp/busymark-m3/upstream` outside Git: CommonMark 0.31.2, Writerside links/reuse, +Notes 6.1.0 API/controller/helper, and Notes 6.0.2/6.1.0 browser router/routes. Private +fixture materials and raw reports remain outside Git. + +## Implementation and verification + +Initial implementation commits: `55c62dd`, `e9f5ab3` and `138c92b`; acceptance was +recorded in `4c3306e`. Post-review corrections below record the current candidate +and its renewed verification; earlier candidate reports remain historical. +Historical release results and artifact identity in this record apply to `138c92b`; +they must not be reused as acceptance of the corrected production payload. + +| Work package | Delivered behavior | +| --- | --- | +| References | Shared revision/identity/occurrence/target model, supported-parser Markdown extraction and existing Writerside index/resolver adapter. Reference occurrences retain their definition destination spans; code, escapes and losing definitions are excluded. Actual parsed heading/element IDs, module origins and instance availability are retained. Incoming/outgoing queries and chooser results have explicit continuation. | +| Index lifecycle | Unopened files, dirty overlays and connected cached notes; debounced generation-fenced workers, batches of 16 documents, digest reuse, completed-batch reuse across superseding edits and cached incoming/outgoing lookup. Known deleted server IDs remain missing; uncached IDs are unavailable. Workspace/file/repository changes invalidate derived data. Unsafe/unreadable sources are unavailable, not inferred missing. No source/history/sync writes during indexing. | +| Production UI | Optional References sidebar and command, document/heading/fragment selection, visible scope/status/context, exact source and target actions, stale revision refresh, existing view/navigation reuse. Source and Editor target chooser preserves manual URLs, selection, permissions, cancellation and XML namespace behavior. Localized strings regenerated. | +| Local maintenance | Ordinary Rename and Cut/Paste prepare immutable reviewed file/folder mappings, incoming/outgoing Markdown and relative media destination edits, including hidden sources and folder descendants. Dirty-file review, identity/revision/inventory/path/symlink/collision revalidation, atomic files, staged history/path remapping and durable interrupted-operation journals. Recovery refuses to overwrite newer external content. Writerside retains its existing structured Preview/Refactor workflow. | +| Portable Notes links | Real server IDs in ordinary installation-scoped HTTPS web URLs. Anonymous bounded public routing observation selects the installation's configured pretty/front-controller form; cached observation permits offline authoring. Exact account/installation matching precedes attachment/external handling; cached offline opening and authenticated repository refresh. Unsynchronized targets and browser-incompatible headings remain explicitly unavailable in the chooser. | +| Retrieval | Distinct complete 200/304 results, validators only with matching cached server bases; 304 never acknowledges edits or clears conflict/checkpoint state. Category/exclude projections preserve omitted fields and pagination parameters, with isolated query scopes and no authoritative collection deletion inference. | + +The native local journey exposed a real FileLocalHistoryStore queue deadlock: history +protection now occurs before its staged remap queue. The controller regression uses the +real file store. Writerside URI components preserve raw Unicode and unresolved variables +while decoding valid percent destinations; accepted rename/removal regressions remain +intact. Schema **6** is unchanged: the optional routing observation uses the existing +account JSON, round-trips older records and preserves checkpoints. No database reset or +migration is needed. + +## Automated verification + +| Gate | Result | +| --- | --- | +| Locked dependency resolution / localization generation | PASS, `flutter pub get --enforce-lockfile`, `flutter gen-l10n`; pins unchanged | +| Analysis | PASS, `flutter analyze --no-pub`, no issues | +| Formatting / localization audit | PASS: 584 Dart files, 0 changes; localization audit included in exhaustive manifest | +| Credential/security scripts | PASS: credential policy and isolated libsecret scripts, both exit 0 | +| Official Writerside conformance | PASS: pinned **2026.08.0328**, original **181** checks and extended fixture **181** checks; authored links/reference definitions/explicit IDs/includes/code assertions passed. Original compatibility baseline unchanged | +| Exhaustive Local History group | PASS: **50**, exit **0**, `test/src/local_history_store_test.dart`, 1m19s | +| Exhaustive remaining group | PASS: **4,241**, **0 failures, 0 skips**, exit **0**; all **207** other current test files exactly once, concurrency 1, 23m23s | +| Linux release / installed clean Snap | Normal `lib/main.dart` Linux release and clean Snap build PASS, exit 0; exact installed artifact **25/25** UI/artifact checks PASS under strict confinement | + +The generated manifest contains **208** current test files. SHA-256: +`a920523a5249056df24e915f5f012a953fbac3f8540f4f1159adcd8d99fe8260`. These are **two +exhaustive groups**, not a clean single-invocation run. Private final logs: +`release-history.log`, `release-remaining.log` and exit files under `/tmp/busymark-m3`. +Earlier exhaustive attempts are retained: first **4,228 passed / 1 failed / 1 skipped** +(a test edited while its compiled suite was running; missing native GTK display); second +**4,232 passed / 2 failed / 0 skipped** (raw Unicode/unresolved-variable decoding). Both +issues were corrected before the final clean groups. The prior `e9f5ab3` run also passed +4,236 remaining tests; the final source adds five deterministic cases. Local History +passed all 50 in every attempt. + +New regressions cover actual/reference-style repeated links, duplicate/explicit heading +IDs, Unicode/percent paths, code exclusions, include origin/ambiguity and instance +changes, dirty overlays, external changes/deletion/recovery, stale navigation and +continuation. Move tests cover incoming/outgoing definitions and media, file/folder +moves, preserved bytes/formatting, review races, collisions, write failures and restart +recovery. Retrieval tests cover omitted IDs/fields, conditional cache availability and +races, account removal and checkpoint isolation. Existing accepted Milestone 1/2 suites +remain in the exhaustive manifest. + +## Live and production desktop acceptance + +Owned disposable HTTPS fixtures: Nextcloud **35.0.1**, Notes **6.0.2** / **6.1.0**, both +**API 1.4**. Separate private profiles/stores, dedicated Login Flow app passwords and +CA-signed localhost certificates; hostname/certificate verification retained. Ports +18448/18447, owned Docker labels `busymark.acceptance=m3`. Real browser Login Flow and +stored-link navigation pass on both root baselines: the authored link's real **Open +link** action opens the intended ID and unique body marker, not merely a successful HTTP +page. Actual browser-copied URL internal navigation after restart is verified on both +baselines, including `/cloud`. + +| Journey | Verified result | +| --- | --- | +| Full live API journey | **41** checks per baseline (including version entries), all passed; conditional 200/304, scoped projections, pending/checkpoint preservation and stored portable URL, plus accepted everyday and uncertainty/conflict/recovery/import/media checks. Omitted-ID category pagination and reduced single-note retrieval passed | +| Local Markdown native UI | **2** checks plus **1** real-process restart check passed, including hidden-source updates through the real History transaction: Source heading chooser, References, ordinary reviewed Rename and Cut/Paste Move, incoming/outgoing media paths; Editor/Source/Reading/Split and restarted links/media | +| Writerside native UI | **3** checks passed: XML semantic chooser, established reviewed topic rename with source preservation; main/shared module navigation, guide/beta instance controls, ambiguous destination and conditional include becoming available, navigation to its origin | +| Notes native UI, each baseline | **6** checks and **2** separate offline-process restart checks passed: Source/Editor chooser, outgoing/incoming panel, real rendered internal URL navigation, title/category identity, pasted URL and missing-target error preserving dirty source | +| Everyday Notes, each baseline | **3** offline-start checks + **13** resumed-process checks passed: Quick Open/search, batch actions, retained offline media, recovery, export/import and real synchronization in all four views | +| Subdirectory installation | `/cloud` front-controller configuration: native/browser/restart PASS; pretty configuration: final native/browser/restart PASS, intended IDs and unique body markers verified | + +Screenshots inspected: References, target chooser, local exact-edit review, Writerside +guide ambiguity/unavailability and beta include resolution. Missing target and final +prefix/browser screenshots inspected; exact edits include the hidden source. +Installed-package References, chooser, exact rename/move reviews, rejected-operation, +missing-target/offline failures and restarted Reading/Split/media screenshots inspected. +Private artifacts remain in +`desktop-local-hidden`, `desktop-writerside-release`, `desktop-notes-final-6.0.2`, +`desktop-notes-verified-6.1.0`, `desktop-6.*-everyday` and `live` under +`/tmp/busymark-m3`. Acceptance tooling corrections retained: asynchronous GTK cut +completion, full refactoring publication wait, fresh collision-free fixtures, missing +Ctrl+2 XTest binding, delayed Nextcloud first-run modal, unique body marker instead of a +browser-hidden first heading, modal dismissal between performance samples, and faithful +browser-required HTTP method/HEAD forwarding in the private proxy. A transient +capability timeout and pre-correction browser/dirty-flag failures are retained +privately; final reruns passed. The dirty-source assertion now verifies exact content +and identity whether still unsaved or durably saved with pending synchronization. + +Browser limits: a mismatched front-controller URL on a pretty installation can open the +most recent note; URL generation observes the installation rather than assuming HTTP 200 +proves identity. Root browser views accept `#heading` in the address bar but do not +expose BusyMark's heading ID in the rendered headings. Portable heading insertion is +disabled; no cross-client heading equivalence is claimed. Recovery/import identities +never redirect old ID-based links. + +## Performance + +Dart **3.13.4** native AOT, SQLite **3.53.4**, Intel i9-9900K / 16 logical CPUs, 48 GiB +host RAM, Ubuntu 24.04.5 / Linux 7.0.0-38. Exhaustive tests and fixture/build +preparation were running concurrently; these are not idle-host timings. Original corpus: +**10,000** notes, **1,279** categories, **250** duplicate titles, **21,785,692** UTF-8 +bytes, **2,000** attachments / **4,096,000** bytes. Added reference corpus: **10,000** +documents, **32,000** occurrences, **23,683,480** UTF-8 bytes. Built with `dart build +cli` in AOT mode; reports/binary outside Git. + +| Measurement | Result | +| --- | --- | +| Reference initial index / one-document incremental index | **200,500 ms / 36.229 ms** | +| Reference query median / p95 | Below measurement timer resolution (reported 0.0 ms) | +| Search during reference indexing | **76.004 ms** | +| Save during reference indexing median / p95 | **10.941 / 19.140 ms** | +| Warm search medians / largest p95 | **47.980–52.854 / 70.295 ms** | +| Save during full search rebuild median / p95 / max | **45.126 / 156.669 / 292.230 ms** | +| Save during query / import median and p95 | **76.428 / 33.837 and 170.795 ms** | +| AOT RSS before/after reference workload / maximum overall | **219,435,008 / 177,504,256 / 219,095,040 bytes** | +| Database size | **118,718,464 bytes** | + +Native release UI: private copied **10,020**-record benchmark store, never uploaded to +real fixtures; Impeller OpenGLES/software rendering on Xvfb `:98`, 100 ms timing polls. +Five Quick Open samples before indexing: **210,233,233,131,223 ms**; during initial +indexing: **526,215,211,118,221 ms** (medians **223 / 215 ms**, initial burst visible). +Save during search **146 ms**; RSS **615,677,952**, maximum **636,846,080** bytes. The +added workload preserves responsive save/search and steady Quick Open; initial indexing +remains a substantial background operation. Private evidence: +`performance-candidate/report.json` and `ui-performance-final/ui-performance.json`. + +## Clean release and installed-package gate + +Candidate source: **`138c92b8714b5bcbf6df6260a371461df8c0c959`**, clean Git archive. +Official Ubuntu 24.04 Snapcraft **8.11.1**: version → expand-extensions → clean → +destructive-mode build using unchanged `snap/snapcraft.yaml`, **PASS, exit 0**. +Final Snapcraft pack with official `SNAPCRAFT_BUILD_INFO=1` from those fresh parts +also **PASS, exit 0**. This embeds the original recipe and package manifest; +embedded recipe SHA-256 matches the checkout. README now makes that flag explicit. +No installed scaffold, older payload or `build_install_snap_local.sh` used. + +Final artifact: `busymark_0.6.2_amd64.snap`, **310,149,120 bytes**, SHA-256: +`2150b0e2a65139eecdabfef502a5308fa0914760725fbb2235542436f9fb823a`. +Recipe SHA-256: `ba4745fc25a3b9e4485b878b3b0c1086a996a11628b38f5b8abadd69fa814af7`. +Private artifact, source, identity report and full logs remain under +`/tmp/busymark-m3` (`snap-final-source`, `snap-artifact.json`, +`snap-helper-provisioned-build.log`, `snap-manifest-build.log`). + +Failed attempts retained: missing extension data/apt metadata, build-package +installation, incorrect image entrypoint, Python module search path after OS +upgrades, Ubuntu iso-codes transfer, missing Node/craftctl on the Docker PATH. +Exact Snapcraft 8.11.1 extension resources and modules are restored. Recipe-declared +SDK/runtime/build snaps use official Store payloads with hash verification; +iso-codes cache repair matches signed apt SHA-256 metadata. Standard links expose +Node **24.21.0** (24/stable revision **12007**) and the matching craftctl helper. +Pins and recipe unchanged; superseded source builds were stopped without accepting +an obsolete artifact. The first successful package lacked optional build info; +the final package adds it without replacing the clean-built application payload. + +Isolated Ubuntu 24.04 VM: official cloud-image SHA-256 +`6a81c37564db9b1ee84e141922625e1d7c5b389b99bb3c572e0243607d5bb4d2`; +TCG software virtualization because `/dev/kvm` is absent; private SSH/profile/keyring. +Snapd **2.77.1**, **strict** confinement with full AppArmor. Core24 **2124**, GNOME +**168**, Mesa **1839**, GTK themes **1535** installed with normal Store assertions. +Interrupted Store transfers are repaired through verified snapd cache payloads, +without weakening confinement. Fixture CA trust retains TLS/hostname verification. + +The exact final snap is installed as **x2** in this VM; root-owned installed-file +SHA-256 matches above, process executable is `/snap/busymark/x2/busymark`, AppArmor +profile is `snap.busymark.busymark (enforce)`. The documented +`password-manager-service` interface is manually connected. No candidate installed +on the user's ordinary host. GUI input drives normal `lib/main.dart`, no acceptance +controller/test entrypoint. Source chooser, incoming/outgoing navigation, exact +reviewed Rename and Cut/Paste Move, preserved link/media bytes, Reading image and +real-process restart with remapped tabs and Split media **PASS**. Input automation +requires explicit focus and current row coordinates after modal/tree changes; +rejected unchanged-name operations leave files intact. Software rendering makes +first-launch/redraw initialization slower than native host runs. + +Installed external HTTPS navigation **PASS** through the real desktop portal and +WebKitGTK browser with verified TLS. Initial missing browser MIME registration +was corrected by installing standard desktop MIME utilities in the VM; initial +tunnel connection refusal was corrected by restoring the private SSH forwarding. +Writerside References/element/include navigation, XML semantic chooser and the +established reviewed topic refactor **PASS**, with source definitions, includes, +TOC, explicit IDs and code exclusions verified. + +Production Settings recognizes Notes **6.0.2 / API 1.4**. Keyring-backed +authenticated publication, a newly authored portable URL in server note **483**, +internal navigation to intended target **482** and its unique marker, native +file-picker media ingestion/upload and durable cache bytes **PASS**. Target title +and category changes retain server ID **482**; offline status reports **1/1**. +The copied profile's older pending edit was resolved through the real conflict +comparison; stale browser-owned locks on these two fixture files were released +using Nextcloud's supported `files:lock --unlock` command. BusyMark preserved +pending edits and displayed its scoped server-lock error before successful retry. + +Installed missing-target failure **PASS**: the explicit missing note error leaves +source identity and its exact edited content intact. The retained requirement is +verified in schema 6 and managed media bytes are durable. Real offline process +restart **PASS**: fixture tunnel disconnected and unreachability verified; previous +PID **32321** exited, new PID **38387** runs the same x2 executable under enforced +confinement. Welcome → connected account Open → Quick Open cached source → stored +portable link opens target **482**, its changed metadata and unique marker; retained +image renders in Reading and Split. Offline capability/refresh errors are explicit +while cached content remains available. Installed report: **25/25 checks PASS**; +screenshots inspected, private in `snap-vm`. + +Final formatting and analysis repeated after the acceptance-tool corrections: +**584 files / 0 changes**, **no issues**, exit 0. The application lockfile SHA-256 +remains `6062c9287c8030d89989584d023b649c990ed5ca7bb40cb5941d6a48d1bcc9a7`. +The source candidate above is the clean release artifact's commit; the final +documentation/tooling handoff adds no production payload changes. No required +acceptance environment or workflow remains unverified. Verified limitations are +the browser heading behavior and substantial initial background indexing described +above. Only owned acceptance processes/fixtures are stopped; private artifact, +VM disk and raw evidence remain outside Git. No merge, push, publication or ordinary +host installation replacement performed. Locked resolution and localization are +repeated after committing the handoff; final Git status is reported with that commit. + +## Post-review corrections — 2026-10-11 + +Correction baseline: branch `feature/nextcloud-notes-workspace`, HEAD +`4c3306e6161229f4319be6e147536fef646a8b9c`, clean tree, no applicable `AGENTS.md`. +Flutter **3.47.5**, Dart **3.13.4**, dependency pins and Notes schema **6** unchanged. +Required CommonMark link/code rules reread from the retained private reference copy. + +* Successful PUT acknowledgments now advance a separate linked-read fence. Older + individual responses cannot replace acknowledged content/base/ETag/revisions. + Editor base generations remain reserved for external observations, so pending + local edits still reconcile correctly. Held GET B → acknowledged PUT B/C → + released GET B verifies content, base, ETag, revision/ack, synchronized state and + SQLite restart persistence; the original implementation failed with content B. +* Refactoring journals now persist phase and complete moved-entry device/inode + identity (birth time where available), type, content hashes and membership. + Identities are recaptured after owned atomic writes and persisted before moving. + Recovery checks paths and identities before restoring anything, uses a no-replace + reverse rename, guards rollback publication and verifies identity again before + deleting evidence. Recreated sources, replaced/identical-byte destinations, + folder replacement/membership and publication races retain evidence. Unedited + moved documents are covered; legacy identity-free journals require manual review. +* The supported parser determines genuine link structure; a literal mask excludes + links starting in code/comments without rejecting code formatting within labels. + Inline, full/collapsed/shortcut reference labels are covered through extraction, + incoming queries and real rename/move; code examples/comments remain unchanged. +* Fragment lookup decodes once against parsed IDs, preserving authored destination + bytes. Real chooser → index → target navigation tests cover `café` and the explicit + ID `élément%20id`, including exact UTF-16 spans, incoming heading queries and a + double-decoding negative case. The fixture uses the supported `{id="…"}` syntax. + +Correction candidate: **`d6f36d94bad8d95f78d1e30475e92dcc54eabcfe`**. Seventeen +new deterministic regressions are included in the current exhaustive suite. + +| Corrected-source gate | Result | +|---|---| +| Current manifest | 208 files: complete Local History store file separately, all other 207 files once per remaining-group attempt | +| Local History group | **50 PASS**, exit 0 | +| First remaining-group attempt | **4,257 PASS / 1 FAIL**, exit 1, 21:54; existing Empty MD Topic UI test exceeded its 10-second activation wait after creating its file | +| Unchanged failing file rerun | **15 PASS**, exit 0, 1:13; no test timeout or expectation changed | +| Complete remaining-group rerun | **4,258 PASS**, exit 0, 21:13; no failing files omitted | +| Static/generation/release checks | Locked resolution and localization generation PASS; localization audit in exhaustive suite PASS; 585 Dart files formatted with zero changes; analysis zero issues; normal Linux release exit 0 | +| Security / Writerside | Both credential scripts invoked with bash PASS; pinned official builder **181 + 181 = 362 PASS**, authored-link assertions PASS | +| Real HTTPS API 1.4 | Notes **6.0.2: 41 PASS**, **6.1.0: 41 PASS**; first cold 6.1.0 capability request failed with a network error, unchanged verified endpoint/retry passed | + +Current AOT benchmark (`dart build cli`, Dart 3.13.4, SQLite 3.53.4, Linux x64): +same 10,000-note/1,279-category/250-duplicate-title corpus, 21,785,692 text bytes, +2,000 attachments/4,096,000 bytes. Reference corpus: 10,000 documents, 32,000 +occurrences, 23,683,480 UTF-8 bytes. Initial reference indexing **213,611 ms**, +incremental document **40.420 ms**, queries below reported timer resolution; +search during indexing **51.702 ms**, save median/p95 **12.260 / 15.016 ms**. +Warm search medians **39.355–60.410 ms**; maximum RSS **217.5 MiB**. Concurrent +exhaustive tests and release/VM activity were present. Initial indexing remains +substantial; reference-era search and save results retain accepted responsiveness. +Full timings and memory observations: `corrections-performance/report.json`. + +Fresh clean Git archive of the correction candidate, unchanged recipe, official +Ubuntu 24.04 Snapcraft **8.11.1**, `SNAPCRAFT_BUILD_INFO=1`: version → extension +expansion → clean → destructive-mode build **PASS, exit 0**. Initial Docker +mountpoint failure occurred before building; separate read-only dependency mounts +fixed it. Previous application intermediates were never reused. Embedded recipe +and resolved lock hashes match the checkout. + +Corrected artifact: `busymark_0.6.2_amd64.snap`, **310,149,120 bytes**, SHA-256 +**`3c91d151e61b560675e4f0f09515c052e500d00266ee6f8a0ff435d3ec991f20`**. +Installed only in the retained isolated VM as **x3**; root-owned artifact hash +matches, executable `/snap/busymark/x3/busymark`, AppArmor profile enforcing. +Documented password-manager-service connection retained; locked disposable +keyring preserved and fixture credential reseeded into a new encrypted keyring. + +Real installed UI: Source chooser inserts both encoded Unicode targets; panel +shows all four incoming/outgoing links Resolved and excludes the fenced example; +keyboard Target location selects the exact explicit heading. Ordinary file Rename +and containing-folder Cut/Paste each review four precise edits. Actual published +files preserve code labels, reference title, fragments, code example and unedited +target bytes. Recovery journals are cleared on success. Process restart and +Reading navigation open `moved/docs/renamed.md` through the code-labeled link. +Installed Notes uses libsecret and verified HTTPS, then opens stored source-note +483 links to target 482, confirmed by its unique marker and renamed title/category. +After taking HTTPS offline and restarting, the same cached URL opens that target +and its retained image; settled screenshots show both content and explicit network +failure messages. No test controllers or older package substitute were used. +Screenshots inspected under `snap-vm/corrections-*`; early empty-frame captures and +premature filesystem assertions are retained, not counted as successful checks. +Previous broad browser/Writerside desktop reports remain historical evidence. + +Private correction logs use `corrections-*` under `/tmp/busymark-m3`, preserving +the original reports and artifact. No ordinary installation, pins or schema changed. diff --git a/docs/development/nextcloud-notes.md b/docs/development/nextcloud-notes.md index 63a8f337..c7888868 100644 --- a/docs/development/nextcloud-notes.md +++ b/docs/development/nextcloud-notes.md @@ -63,6 +63,24 @@ Unknown/malformed/prerelease app versions conservatively disable DELETE. An unsupported cleanup attempt explains the limitation before queuing any operation; notes and attachment upload/download remain available. +## Retrieval coverage + +[API coverage](nextcloud-notes-api-coverage.md) records the supported request +scopes. Single-note conditional retrieval returns a distinct not-modified result +with its complete cached server representation. Linked-note opening benefits +from this path; it does not add a synchronization poller. A 304 does not +acknowledge pending writes, clear conflicts, or replace dirty content. Cached +notes open offline and an uncached linked ID uses the existing authenticated +repository boundary. Account and base generations fence asynchronous replies. + +Optional category/projection queries retain their immutable request scope and +all pagination parameters. Missing fields remain absent, including an explicitly +excluded ID; reduced payloads never become complete NoteState objects. These +reads use no collection validator/checkpoint and never infer account deletions. +The authoritative full-account list strategy and local category/search workflows +remain unchanged. Portable note URLs and reference workflows are described in +[the user guide](../references.md). + ## Authentication and credential boundary Anonymous Login Flow v2 opens the returned URL with `url_launcher` in the default @@ -101,12 +119,16 @@ Parser labels are logical identifiers only. Remote workspaces install no disk watchers, discover no Git repository and expose no path/file-manager actions. `NotesStore` uses direct `sqlite3` 3.7.0 in a dedicated isolate under application -support storage. Schema version 2 includes accounts, notes, a coalesced per-note +support storage. Schema version 3 includes accounts, notes, a coalesced per-note outbox and attachment metadata/bytes. The v1 → v2 migration retains existing records and adds optional creation-attempt metadata in note/outbox JSON; the version fence prevents older clients from discarding it. Existing uncertain creations without that metadata remain unresolved. Migrations use transactions and reject -newer unsupported schemas. SQLite uses WAL, `synchronous=FULL`, foreign keys and +newer unsupported schemas. The v2 → v3 transaction fences new failure states, +retry deadlines/budgets, settings attempts, media validation and local activity. +Missing historical edit times remain missing; server modified time is the ordering +fallback. Ambiguous legacy creation histories require deliberate recovery. +SQLite uses WAL, `synchronous=FULL`, foreign keys and a busy timeout. Notes content, revision, base state, outbox and synchronization checkpoint commit atomically. The Notes directory is mode 0700 and database 0600 on Linux. Managed attachment paths reject traversal and symbolic links. @@ -125,11 +147,23 @@ copy in session/recovery JSON. A clean shutdown may retain a durable outbox. `NotesApiClient` takes an injectable HTTP client. `NotesRepository` serializes local mutations and runs at most one synchronization pass per account. The -controller requests a subsequent pass when another local save commits during -an upload. Transient network/server/lock failures receive six delayed retry -attempts (5, 10, 20, 40, 80, 160 seconds), then explicit Refresh remains available. -Conflicts, uncertain creation, authentication and forbidden errors require -deliberate resolution. +`NotesSyncCoordinator` routes opening, committed edits, manual Refresh and desktop +lifecycle events through that same mechanism. It coalesces one follow-up when +local work arrives during a pass; manual Refresh is awaitable. Visible active +workspaces check every 60 seconds, including authenticated read-side connectivity +recovery. Inactive-but-visible windows keep polling; hidden/minimized windows and +other screens suspend healthy polling. Focus/resume coalesces checks completed +within ten seconds. Timers/listeners are disposed and account generations fenced. +Transient writes receive six delayed retries (5, 10, 20, 40, 80, 160 seconds). +Healthy reads/focus never reset their durable exhausted budgets. Successful read-side +connectivity recovery releases only network failures. Valid Retry-After delta/date +deadlines are durable and never shortened. Conflicts, uncertain outcomes, +authentication, permissions, quota and permanent rejection require correction; +a relevant edit or deliberate Retry can release a rejected operation. Separate +last-successful-server-check time means a complete GET/304, not all writes/media +having synchronized. Local activity uses an injectable microsecond clock and +writable API modified uses Unix seconds; internal attachment publication retains +the human edit time. Read-only favorite writes still contain only favorite. List synchronization sends `If-None-Match`, the **previous server Last-Modified** as `pruneBefore`, `chunkSize` and the opaque chunk cursor. Header @@ -216,16 +250,22 @@ are rejected and repeated confirmation reuses that replacement. The original uncertain draft remains recoverable and blocked. Cancel and dismissal leave it untouched. No suspected server duplicate is automatically deleted. Legacy uncertain records without creation evidence cannot be adopted by guessing; -the existing database migrations and startup recovery remain unchanged. +schema migration and startup recovery preserve their uncertainty and work. Read-only notes block content/title/category editing while favorite remains writable under the documented contract. A note becoming read-only does not discard edits already captured locally. `error: true` responses carry unavailable state; their exception-text content never replaces known-good cached content. -Errors are classified as reconnect required (401), forbidden/read-only (403), -missing/deleted (404), conflict (412), locked (423), insufficient storage (507), -transport/offline, server errors and safely reported unknown responses. A locked +Errors carry operation scope as well as rejection/transient/uncertain classification. +Only individual-note absence or a completed authoritative ID set establishes note +deletion. Collection/settings/capability and attachment 404s preserve notes, +bases, revisions, outbox and bytes. Legacy poisoned server edits reconcile against +remote ETags, using the existing conflict workflow for divergence. Positive durable +never-sent evidence permits draft recovery; ambiguous old creation histories retain +work and require deliberate separate-note recovery. A 429 preserves a not-before +deadline; rejected 4xx operations do not loop. Lock/network/5xx/invalid write +responses remain conservative uncertain create/upload outcomes. A locked note retains its content and outbox and does not prevent unrelated notes from synchronizing. Issue 1940 describes locks retained by Notes/Text editing sessions; the client cannot remove those server locks or claim live collaboration. @@ -263,6 +303,10 @@ Only actual Markdown/HTML destinations are rewritten; namespace text in prose or code remains literal. History restoration can restage deleted attachment bytes, and recovery as a new note clones managed bytes under the new logical identity. Neither operation reuses another note's attachment ownership. +API paths/filenames are raw strings. Markdown destinations are decoded exactly +once and raw segments encoded exactly once; literal percent sequences remain +filename characters. Upload response paths commit durably before reference +replacement, so later local failures cannot cause a second upload. Attachment downloads have a dedicated streaming path and share the existing local asset ingestion limit, `maximumManagedAssetBytes` (**100 MiB**). Advertised @@ -301,6 +345,34 @@ WYSIWYG, HTML and PDF share this resource boundary. Filesystem/Writerside projec workspace-wide search/replace are explicitly unavailable for Notes; per-note search/replace uses the normal editor and save pipeline. +## Settings and maintained capabilities + +The account settings screen loads the server Notes application's notesPath and +fileSuffix, with explicit Save/Cancel, partial updates, custom suffixes and returned +normalization. These preferences affect other clients; path changes do not migrate +files and suffix changes do not rename existing notes. Dirty buffers save through +the normal boundary before transitions. Pending notes/conflicts/attachment work +block collection-affecting settings changes; synchronization is serialized and +mutations fenced. A durable exact settings attempt survives a lost PUT response +and is reconciled by GET before success/retry. Relevant changes reset the old +checkpoint and perform a full authoritative read. + +Capabilities refresh on first connected use per process, explicit Refresh, +reconnect and hourly active use. OCS and case-insensitive X-Notes-API-Versions +headers share version parsing. API advertisement is separate from application +version evidence; a header never independently enables attachment deletion. +Verified unsupported advertisements block remote operations while retaining local +editing/recovery. Transient refresh failure retains verified metadata. Updates +merge onto current account/checkpoint state and pre-reconnect responses are fenced. + +Managed media validates after observed remote note changes, on explicit Refresh +for cached attachments used by the active note (including 304), and becomes stale +after five minutes while displayed. Both repository bytes and document contexts +refresh. Failed refresh retains usable old bytes without advancing validation. +Requests coalesce; late downloads are fenced by account/ownership/deletion/media +generation. Materializations use content identity and remove superseded files. +No whole-library uncached media prefetch is performed. + ## Verification Focused tests cover authentication/URI/capabilities/keyring failures, mocked @@ -355,3 +427,133 @@ The current [Text source](https://github.com/nextcloud/text) and describe web-editor ownership and internal collaborative machinery, not a documented supported external native collaboration API. A future official native API would require a separate product decision. + +Milestone 1 implementation, regression names, fixture versions and acceptance +results are maintained in [nextcloud-notes-milestone-1.md](nextcloud-notes-milestone-1.md). +The native disposable acceptance target is built with: + +```sh +flutter build linux --release -t tools/nextcloud_notes_desktop_acceptance.dart +# Private credentials/CA/output and an explicitly disposable Docker container: +WEBKIT_DISABLE_DMABUF_RENDERER=1 build/linux/x64/release/bundle/busymark \ + /private/app-credentials.json /private/test-ca.pem /private/desktop-output \ + disposable-nextcloud-container +``` + +Use a disposable XDG profile/display. The target drives production UI/controllers; +connectivity loss is injected around real authenticated requests. Its same-path +replacement writes only an owned image within the named disposable container. +Rebuild the normal default release target afterward. + +## Everyday Notes workspace + +The existing sidebar offers All notes, Favorites, Uncategorized, Recovery and a +collapsible category tree. Category counts and selections include descendants; +New note creates directly in the selected category. Categories come from notes, +so there are no persistent empty server folders. Sort by local editing activity +(newest or oldest), or by title. Click selects and opens; Ctrl-click toggles, +Shift-click extends a range, and Ctrl+A selects the current list. The checkbox +marks selection, the edge marks focus, and the document icon marks the open note. +The Selected action and a row's context menu review the exact target titles +before setting/removing favorites or moving notes. A move can introduce a category. +Read-only notes support favorite changes. Partial results retain successes and +identify skipped, conflicting or failed notes; resolve conflicts through the +existing review workflow. + +Ctrl+P opens Quick Open for the active workspace. Cached unopened Notes titles +and categories participate alongside local Markdown/Writerside files in their +own workspaces. Exact and prefix title matches rank first. Arrow keys choose, +Enter opens, and Escape dismisses. Ctrl+Shift+P still opens the command palette. + +The sidebar Search control and workspace search use one indexed Notes search +workflow. Terms combine with AND; quote a phrase, use `title:meeting` or +`category:"Work/Team"`, and select Whole word when needed. Ordinary punctuation, +SQL/FTS operators and wildcard characters are literal. Partial terms, short +queries, Unicode and decomposed accents are supported. Results include local +unsynchronized text and overlay dirty open buffers without saving. Results show +context, individual occurrences, loading/indexing/errors and Show more when +truncated. Opening a changed result rechecks its revision and content digest and +relocates the match. The Source location button explicitly selects source for +syntax that the rendered view cannot represent. Remote workspace replacement +remains disabled. A missing derived index rebuilds without changing notes/outbox. + +Make available offline retains an individual note or a category and descendants. +Individual and category requirements are independent and survive restart. Moving +notes into a retained category or adding supported managed references schedules +those bytes through the existing download machinery. Status shows text, required +media counts, missing items and failures. Cancel stops that requirement (a note +cancellation also stops category-derived work for that note); Retry deliberately +resumes. Completed downloads remain retained. Removing a requirement removes no +notes, remote attachments or pending sole copies. External URLs are identified +but never downloaded automatically. Available offline describes local bytes; +last server check describes freshness; Saved locally / sync pending describes +unacknowledged changes. These states can coexist. + +Recovery lists fully deleted local records for the current account. Deletion +conflicts with pending work stay in the normal list and conflict workflow. +Recovery previews retained text and Local History revisions, checks actual media +availability and recovers as a new identity. It never overwrites an existing +note. History settings/retention still apply; unavailable history is explained. +This is local recovery, not Nextcloud server trash or server history. + +## Portable Notes snapshots and import + +The sidebar destination menu exports the current category/subtree or whole +account. The existing per-note Save local copy remains available. Dirty buffers +in scope must save successfully through the normal save workflow before export. +A cancelled/failed save produces no completed export. Documents represent the +captured local working version, including saved unsynchronized changes; exporting +a conflict does not resolve it. Choose a parent directory: BusyMark stages then +publishes a new unique `BusyMark-notes-…` directory, without overwriting existing +files. Cancelling or a filesystem failure removes unpublished staging. Missing +managed bytes and unsupported/external dependencies are listed as omissions. +If attachment bytes refresh or the account is removed during capture/copying, +publication stops and asks for a fresh snapshot; a mixed snapshot is not published. + +Each snapshot contains ordinary `.md` documents in category directories and +companion media. Only recognized attachment destinations are rewritten; prose, +code, external URLs and unrelated document links remain unchanged. Sanitized, +UTF-8-bounded, collision-safe names are mapped to exact original metadata in +`busymark-notes.json`. The manifest has `format: "busymark-notes"`, `version: 1`, +`capturedAt`, `complete` and a `documents` array. Each document records `file` +(relative Markdown path), exact `title`, `category`, `favorite`, `activityMicros` +(local editing time), `serverModified` (server seconds), `workingVersion`, +`unresolvedConflict` and `omissions`. It contains no credentials, local/server +identities, outbox or full history. No proprietary front matter is inserted. +Markdown and media remain usable without BusyMark. + +Import accepts a Markdown file, a folder, or a version-1 snapshot directory. The +review shows selected documents, editable target categories, recognized media, +missing/unsafe references and collisions. Uncheck an item to skip it; a collision +imports a distinct new note. Existing notes are never overwritten. Source paths, +symlinks, versions, size limits and reviewed file digests are validated. Companion +files must remain inside the selected source directory (the document's containing +directory for a single-file import); external URLs are not fetched. Unsafe or +missing media remains an explicit incomplete item for review. + +Imports commit each new local identity, attachment bytes, outbox and source-item +association together. Resuming a specific interrupted operation after +cancellation/restart does not duplicate successful items. A new reviewed import +creates distinct notes even when the source content is unchanged. +Imported locally means durable local content; waiting to synchronize means +publication has not been acknowledged. The existing uncertain-create/upload +workflow applies to interrupted publication. One failed item retains the others. + +### Import operations and Recovery search + +Each confirmed import is a separate operation. Selecting the same source again +creates distinct notes and uses the newly reviewed categories and media. When +an interrupted operation exists for that source, choose **Start a new import** +or **Resume interrupted import** explicitly. Resume reloads the persisted review +and identifies completed items as **Already imported in this operation**; it +does not repeat their creation or publication. Successful items keep their +original identities even when another item fails. Safe relative images such as +`./images/photo.png` and `../images/photo.png` are imported when their resolved +files stay inside the selected source folder; symlinks and outside references +remain excluded. + +Search in **Recovery** searches retained deleted records for the current account +and opens their recovery preview. Ordinary Notes search excludes these records. +The preview continues to offer retained Local History revisions and recovery +as a new note; it does not access server history or trash. Deleted attachments +retained for recovery do not count as live offline availability. diff --git a/docs/references.md b/docs/references.md new file mode 100644 index 00000000..85ae9c96 --- /dev/null +++ b/docs/references.md @@ -0,0 +1,81 @@ +# References and links + +Open **References** from the sidebar menu or Command Palette. The panel follows +the current document and shows its workspace/account scope. Switch between +Incoming and Outgoing, or choose a parsed heading, topic element, or reusable +fragment. Rows show the authored context, destination, and resolution status. +Writerside rows retain their module and origin; **Unavailable in this context** +can mean an instance condition rather than a missing topic. **Show more results** +continues through the complete result set. + +Select a row to follow its reference. The row menu also provides **Source +location** and **Target location**. These actions check the current source +revision; stale positions refresh instead of selecting unrelated text. The +normal navigation keeps your view where it can show the target. Use Source +location for syntax that Editor or Reading cannot represent. + +The index includes unopened workspace documents and unsaved buffer content. +It is derived data: rebuilding it does not save files or alter Notes history or +synchronization. Unreadable files, unsafe paths, and documents larger than 2 MiB +are unavailable for inspection. Code examples and escaped link syntax do not +create references. External destinations without a supported internal target +are marked Unsupported; normal external-link navigation remains available. + +## Insert or edit a link + +Select text in Source or Editor and use the existing Link action (Ctrl+K). +Search the target chooser for a document/topic or an actual parsed heading ID. +Unicode heading and explicit IDs use ordinary percent-encoded URL fragments; +References resolves them to the actual parsed ID without changing authored bytes. +Manual URL entry remains available. XML topics receive semantic Writerside +links through the existing adapter, including namespace and module origin. +Reading follows links; it does not insert them. Cancelling restores focus and +makes no edit. A changed document, selection, workspace, or edit permission +invalidates the pending selection. + +## Rename and move local Markdown + +Use the ordinary Files sidebar **Rename**, or **Cut** and **Paste** to move a +file or folder. Resolve unsaved affected buffers through the save review first. +BusyMark then shows the files and exact destination edits before Apply. +Incoming document links and outgoing relative links/media are reviewed together. +Reference definitions are edited once; link labels, text, titles, queries, +fragments, formatting, encoding and line endings are preserved. Code and prose +are not globally replaced. Writerside topic operations retain their established +Rename Preview workflow. + +If files, folder membership, filesystem identity or the destination change +between review and Apply, prepare and review the operation again. Unsafe paths, +uninspectable Markdown, mixed line endings and unavailable Local History +protection block the operation with an explanation. Cancellation before Apply +makes no refactoring changes. After an interrupted publication, References +exposes Recovery. Review the pending recovery before applying it; newer external +content prevents automatic restoration and the recovery evidence remains. +Recovery also verifies the moved file/folder identity, including entries that had +no reference edits. Recreated original paths or replaced destinations require +manual review and retain the journal. Older journals without identity evidence +are retained for manual review rather than inferred from path existence. + +## Portable Nextcloud note links + +Choose a synchronized note to insert its ordinary HTTPS Notes web URL. +BusyMark first observes the installation's public routing configuration and uses +`https://server/installation/index.php/apps/notes/note/123` or its configured +pretty form, `https://server/installation/apps/notes/note/123`. This observation +is cached for offline authoring. If the route cannot be confirmed, the chooser +explains the failure and manual URL entry remains available. The real server ID +survives title/category changes. Both forms are recognized when pasted from a +browser. BusyMark intercepts only the exact connected +installation; other URLs retain external navigation. Account removal disables +that account's internal link resolution. + +Cached targets open offline, including after a process restart. An uncached +recognized target uses authenticated individual-note retrieval. Deleted, +forbidden, unavailable and offline targets produce explicit errors. Recovery or +import creates a new identity and does not redirect an old note ID to it. + +A never-synchronized note has no confirmed server ID and cannot supply a +portable target URL. The chooser explains this rather than creating another +note. Note heading targets are disabled: the verified Notes browser view does +not expose BusyMark's parsed Markdown heading IDs. BusyMark does not +invent a persistent link syntax for this limitation. diff --git a/docs/search-and-replace.md b/docs/search-and-replace.md index 8fdd98d2..75119d04 100644 --- a/docs/search-and-replace.md +++ b/docs/search-and-replace.md @@ -1,8 +1,30 @@ # Search and replace Press **Ctrl+F**, choose **Search**, or use the command palette to search the -open workspace. The search controls use the same query and options for the -active document and workspace results. +open workspace. For local Markdown and Writerside workspaces, the search controls +use the same query and options for the active document and workspace results. + +## Quick Open and Notes search + +**Ctrl+P** opens Quick Open for titles and local paths or Notes categories. +Exact/prefix title matches appear first. Use the arrow keys and Enter to open a +result, or Escape to dismiss; **Ctrl+Shift+P** remains the command palette. + +In a Nextcloud Notes workspace, the sidebar search and workspace Search action +open the same indexed search dialog, including unopened cached notes, saved +pending edits, and unsaved open buffers. Terms use AND; quotes match phrases, +`title:` restricts a term to the title, and `category:` restricts the category. +For example, `"alpha beta" café category:"Work/子"` combines all three restrictions. +Whole word is optional. Ordinary punctuation and technical identifiers are +literal; Notes search does not expose regular-expression or FTS operators. + +Results show contextual highlights. Select a result to navigate in the current +view where supported, or choose its source-location action for Markdown syntax +that has no rendered counterpart. Changed documents are checked before applying +stored offsets. Loading/indexing, errors, empty results, and **Show more** are +explicit. Workspace-wide replacement remains unavailable for Notes. + +The remaining sections describe local workspace search and replacement. ## Search options diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index 8489ba65..a423f4ba 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -3419,5 +3419,97 @@ "nextcloudPossibleCandidate": "تطابق محتمل بعد تنقية الطلب؛ الهوية غير مؤكدة.", "nextcloudSelectCandidate": "اختر ملاحظة على الخادم لمراجعتها", "nextcloudCreateSeparate": "إنشاء ملاحظة منفصلة", - "nextcloudCreateSeparateWarning": "قد يكون الطلب الأول قد أنشأ ملاحظة على الخادم بالفعل. ينشئ هذا ملاحظة منفصلة بعملك المحلي المحفوظ. لن تُحذف أي ملاحظة يُشتبه بأنها مكررة." + "nextcloudCreateSeparateWarning": "قد يكون الطلب الأول قد أنشأ ملاحظة على الخادم بالفعل. ينشئ هذا ملاحظة منفصلة بعملك المحلي المحفوظ. لن تُحذف أي ملاحظة يُشتبه بأنها مكررة.", + "wsSemanticFormats": "تنسيق دلالي", + "wsUiControl": "عنصر تحكم في الواجهة", + "wsFilePath": "ملف / مسار", + "wsUiPath": "مسار الواجهة", + "wsDefinitionList": "قائمة تعريفات", + "wsIncludeContent": "تضمين محتوى قابل لإعادة الاستخدام", + "wsVariableReference": "مرجع متغير", + "wsConvertList": "تحويل القائمة إلى إجراء", + "wsElementProperties": "خصائص العنصر", + "wsTopicProperties": "خصائص الموضوع", + "wsCollapsible": "قابل للطي", + "wsPublishedState": "الحالة الأولية عند النشر", + "wsExpanded": "موسع", + "wsCollapsed": "مطوي", + "wsCollapsedTitle": "العنوان عند الطي", + "wsTerm": "المصطلح", + "wsListLayout": "تخطيط القائمة", + "wsSyncGroup": "مجموعة المزامنة", + "wsTabKey": "مفتاح علامة التبويب", + "wsSwitcherLabel": "تسمية المبدل", + "wsNavigationElements": "عناصر التنقل", + "wsNavigationDepth": "عمق التنقل", + "wsAddItem": "إضافة عنصر", + "wsStep": "خطوة", + "wsPropertyInvalid": "قيمة خاصية غير صالحة", + "wsFilter": "عامل التصفية", + "wsIdentifier": "معرّف العنصر", + "wsTldr": "ملخّص (TLDR)", + "wsFullLayout": "عرض كامل", + "wsMediumLayout": "أعمدة متساوية", + "wsNarrowLayout": "مصطلح ضيق", + "wsTitle": "العنوان", + "wsSwitcherKey": "مفتاح المبدّل", + "nextcloudServerSettings": "إعدادات Notes على الخادم", + "nextcloudNotesPath": "مسار مجلد الملاحظات", + "nextcloudFileSuffix": "لاحقة ملفات الملاحظات الجديدة", + "nextcloudServerSettingsExplanation": "تؤثر إعدادات Notes هذه على العملاء الآخرين. تغيير المسار لا ينقل الملفات؛ وتغيير اللاحقة لا يعيد تسمية الملاحظات الحالية.", + "nextcloudSettingsSaved": "حُفظت إعدادات الخادم", + "nextcloudSettingsNormalized": "حُفظت القيم بعد توحيدها على الخادم", + "nextcloudPendingUpload": "رفع المرفق قيد الانتظار", + "nextcloudServerCheck": "آخر فحص للخادم: {time}", + "nextcloudSettingsLoading": "جارٍ تحميل إعدادات الخادم", + "@nextcloudSettingsLoading": {"description": "Accessible progress label while reading server Notes settings."}, + "nextcloudSettingsSaving": "جارٍ حفظ إعدادات الخادم", + "@nextcloudSettingsSaving": {"description": "Accessible progress label while writing server Notes settings."}, + "nextcloudPreviousLocalEdit": "التعديل المحلي السابق", + "nextcloudUsePreviousEdit": "استخدام التعديل السابق", + "quickOpen": "فتح سريع", + "notesAll": "كل الملاحظات", + "notesUncategorized": "بلا تصنيف", + "notesRecovery": "استعادة", + "notesRecentlyEdited": "المعدلة مؤخراً", + "notesOldestEdited": "الأقدم تعديلاً", + "notesTitleAscending": "العنوان تصاعدياً", + "notesTitleDescending": "العنوان تنازلياً", + "notesMove": "نقل إلى تصنيف", + "notesRemoveFavorite": "إزالة من المفضلة", + "notesOffline": "إتاحة دون اتصال", + "notesOfflineRemove": "إزالة الاحتفاظ دون اتصال", + "notesIndexing": "جارٍ فهرسة الملاحظات…", + "notesSearchHelp": "تُجمع الكلمات باستخدام AND. استخدم \"عبارات مقتبسة\" و title: و category:.", + "notesSourceLocation": "الموضع في المصدر", + "notesRecoveryHelp": "الملاحظات المحلية المحتفظ بها والسجل المحلي فقط. سلة المحذوفات وسجل الخادم غير متاحين.", + "notesImport": "استيراد ملاحظات", + "notesExport": "تصدير ملاحظات", + "notesImportDistinct": "استيراد كملاحظة مستقلة", + "notesAvailableOffline": "متاح دون اتصال", + "notesIncomplete": "غير مكتمل", + "notesSelected": "الملاحظات المحددة", + "notesNoHistory": "لا توجد مراجعات محفوظة. قد يكون السجل معطلاً أو منتهياً أو ممسوحاً.", + "notesTextAvailable": "النص متاح محليًا", + "notesLocalVersion": "نسخة العمل المحلية", + "notesAttachments": "المرفقات", + "notesSkipped": "تم التخطي", + "notesFailed": "فشل", + "notesCompleted": "مكتمل", + "notesImportNew": "بدء استيراد جديد", + "notesImportResume": "استئناف الاستيراد المنقطع", + "notesImportAlreadyImported": "تم الاستيراد بالفعل في هذه العملية", + "references": "المراجع", + "referenceIncoming": "الواردة", + "referenceOutgoing": "الصادرة", + "referenceChooseTarget": "اختر هدف الرابط", + "referenceWholeDocument": "المستند بالكامل", + "referenceSourceLocation": "موقع المصدر", + "referenceTargetLocation": "موقع الهدف", + "referenceResolved": "تم الحل", + "referenceMissing": "مفقود", + "referenceAmbiguous": "ملتبس", + "referenceUnavailable": "غير متاح في هذا السياق", + "referenceUnsupported": "غير مدعوم", + "referenceNoPortableId": "ليس لهذا الهدف عنوان URL محمول ومُتحقَّق منه للملاحظة." } diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 7f2644d0..5ceb1a67 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -3419,5 +3419,97 @@ "nextcloudPossibleCandidate": "Mögliche Übereinstimmung nach Bereinigung der Anfrage; Identität unbestätigt.", "nextcloudSelectCandidate": "Servernotiz zur Prüfung auswählen", "nextcloudCreateSeparate": "Separate Notiz erstellen", - "nextcloudCreateSeparateWarning": "Die erste Anfrage hat möglicherweise bereits eine Servernotiz erstellt. Dies erstellt eine separate Notiz mit Ihrer behaltenen lokalen Arbeit. Vermutete Duplikate werden nicht gelöscht." + "nextcloudCreateSeparateWarning": "Die erste Anfrage hat möglicherweise bereits eine Servernotiz erstellt. Dies erstellt eine separate Notiz mit Ihrer behaltenen lokalen Arbeit. Vermutete Duplikate werden nicht gelöscht.", + "wsSemanticFormats": "Semantische Formatierung", + "wsUiControl": "UI-Steuerelement", + "wsFilePath": "Datei / Pfad", + "wsUiPath": "UI-Pfad", + "wsDefinitionList": "Definitionsliste", + "wsIncludeContent": "Wiederverwendbaren Inhalt einbinden", + "wsVariableReference": "Variablenreferenz", + "wsConvertList": "Liste in Prozedur umwandeln", + "wsElementProperties": "Elementeigenschaften", + "wsTopicProperties": "Themeneigenschaften", + "wsCollapsible": "Einklappbar", + "wsPublishedState": "Anfangszustand bei Veröffentlichung", + "wsExpanded": "Ausgeklappt", + "wsCollapsed": "Eingeklappt", + "wsCollapsedTitle": "Titel im eingeklappten Zustand", + "wsTerm": "Begriff", + "wsListLayout": "Listenlayout", + "wsSyncGroup": "Synchronisierungsgruppe", + "wsTabKey": "Registerschlüssel", + "wsSwitcherLabel": "Beschriftung des Umschalters", + "wsNavigationElements": "Navigationselemente", + "wsNavigationDepth": "Navigationstiefe", + "wsAddItem": "Element hinzufügen", + "wsStep": "Schritt", + "wsPropertyInvalid": "Ungültiger Eigenschaftswert", + "wsFilter": "Inhaltsfilter", + "wsIdentifier": "Element-ID", + "wsTldr": "Kurzfassung (TLDR)", + "wsFullLayout": "Volle Breite", + "wsMediumLayout": "Gleiche Spalten", + "wsNarrowLayout": "Schmale Begriffsspalte", + "wsTitle": "Titel", + "wsSwitcherKey": "Umschaltschlüssel", + "nextcloudServerSettings": "Notes-Einstellungen auf dem Server", + "nextcloudNotesPath": "Pfad des Notizordners", + "nextcloudFileSuffix": "Dateiendung neuer Notizen", + "nextcloudServerSettingsExplanation": "Diese Notes-Einstellungen gelten auch für andere Clients. Ein Pfadwechsel verschiebt keine Dateien; eine neue Endung benennt bestehende Notizen nicht um.", + "nextcloudSettingsSaved": "Servereinstellungen gespeichert", + "nextcloudSettingsNormalized": "Mit vom Server normalisierten Werten gespeichert", + "nextcloudPendingUpload": "Anhang wartet auf Upload", + "nextcloudServerCheck": "Letzte Serverprüfung: {time}", + "nextcloudSettingsLoading": "Servereinstellungen werden geladen", + "@nextcloudSettingsLoading": {"description": "Accessible progress label while reading server Notes settings."}, + "nextcloudSettingsSaving": "Servereinstellungen werden gespeichert", + "@nextcloudSettingsSaving": {"description": "Accessible progress label while writing server Notes settings."}, + "nextcloudPreviousLocalEdit": "Vorherige lokale Änderung", + "nextcloudUsePreviousEdit": "Vorherige Änderung verwenden", + "quickOpen": "Schnell öffnen", + "notesAll": "Alle Notizen", + "notesUncategorized": "Ohne Kategorie", + "notesRecovery": "Wiederherstellung", + "notesRecentlyEdited": "Zuletzt bearbeitet", + "notesOldestEdited": "Älteste Bearbeitung", + "notesTitleAscending": "Titel A–Z", + "notesTitleDescending": "Titel Z–A", + "notesMove": "In Kategorie verschieben", + "notesRemoveFavorite": "Favorit entfernen", + "notesOffline": "Offline verfügbar machen", + "notesOfflineRemove": "Offline-Aufbewahrung entfernen", + "notesIndexing": "Notizen werden indiziert…", + "notesSearchHelp": "Begriffe verwenden UND. Verwenden Sie \"Wortgruppen\", title: und category:.", + "notesSourceLocation": "Quellposition", + "notesRecoveryHelp": "Nur lokal aufbewahrte Notizen und lokaler Verlauf. Server-Papierkorb und Server-Verlauf sind nicht verfügbar.", + "notesImport": "Notizen importieren", + "notesExport": "Notizen exportieren", + "notesImportDistinct": "Als eigene Notiz importieren", + "notesAvailableOffline": "Offline verfügbar", + "notesIncomplete": "Unvollständig", + "notesSelected": "Ausgewählte Notizen", + "notesNoHistory": "Keine aufbewahrten Versionen. Der Verlauf kann deaktiviert, abgelaufen oder gelöscht sein.", + "notesTextAvailable": "Text lokal verfügbar", + "notesLocalVersion": "Lokale Arbeitsversion", + "notesAttachments": "Anhänge", + "notesSkipped": "Übersprungen", + "notesFailed": "Fehlgeschlagen", + "notesCompleted": "Abgeschlossen", + "notesImportNew": "Neuen Import starten", + "notesImportResume": "Unterbrochenen Import fortsetzen", + "notesImportAlreadyImported": "In diesem Vorgang bereits importiert", + "references": "Verweise", + "referenceIncoming": "Eingehend", + "referenceOutgoing": "Ausgehend", + "referenceChooseTarget": "Linkziel auswählen", + "referenceWholeDocument": "Gesamtes Dokument", + "referenceSourceLocation": "Quellposition", + "referenceTargetLocation": "Zielposition", + "referenceResolved": "Aufgelöst", + "referenceMissing": "Fehlt", + "referenceAmbiguous": "Mehrdeutig", + "referenceUnavailable": "In diesem Kontext nicht verfügbar", + "referenceUnsupported": "Nicht unterstützt", + "referenceNoPortableId": "Dieses Ziel hat keine bestätigte portable Notiz-URL." } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 30936f30..f8fa468b 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -2856,5 +2856,104 @@ "nextcloudPossibleCandidate": "Possible sanitized request match; identity is unconfirmed.", "nextcloudSelectCandidate": "Select a server note to review", "nextcloudCreateSeparate": "Create a separate note", - "nextcloudCreateSeparateWarning": "The first request may already have created a server note. This creates a separate note with your retained local work. No suspected duplicate will be deleted." + "nextcloudCreateSeparateWarning": "The first request may already have created a server note. This creates a separate note with your retained local work. No suspected duplicate will be deleted.", + "wsSemanticFormats": "Semantic formatting", + "wsUiControl": "UI Control", + "wsFilePath": "File / Path", + "wsUiPath": "UI Path", + "wsDefinitionList": "Definition List", + "wsIncludeContent": "Include Reusable Content", + "wsVariableReference": "Variable Reference", + "wsConvertList": "Convert List to Procedure", + "wsElementProperties": "Element Properties", + "wsTopicProperties": "Topic Properties", + "wsCollapsible": "Collapsible", + "wsPublishedState": "Published initial state", + "wsExpanded": "Expanded", + "wsCollapsed": "Collapsed", + "wsCollapsedTitle": "Collapsed title", + "wsTerm": "Term", + "wsListLayout": "List layout", + "wsSyncGroup": "Synchronization group", + "wsTabKey": "Tab key", + "wsSwitcherLabel": "Switcher label", + "wsNavigationElements": "Navigation elements", + "wsNavigationDepth": "Navigation depth", + "wsAddItem": "Add item", + "wsStep": "Step", + "wsPropertyInvalid": "Invalid property value", + "wsFilter": "Filter", + "wsIdentifier": "Element ID", + "wsTldr": "TLDR", + "wsFullLayout": "Full width", + "wsMediumLayout": "Equal columns", + "wsNarrowLayout": "Narrow term", + "wsTitle": "Title", + "wsSwitcherKey": "Switcher key", + "nextcloudServerSettings": "Server Notes settings", + "nextcloudNotesPath": "Notes folder path", + "nextcloudFileSuffix": "New-note file suffix", + "nextcloudServerSettingsExplanation": "These server Notes settings affect other clients. Changing the path does not migrate files; changing the suffix does not rename existing notes.", + "nextcloudSettingsSaved": "Server settings saved", + "nextcloudSettingsNormalized": "Saved with server-normalized values", + "nextcloudPendingUpload": "Attachment upload pending", + "nextcloudServerCheck": "Last server check: {time}", + "@nextcloudServerCheck": {"placeholders": {"time": {"type": "String"}}}, + "nextcloudSettingsLoading": "Loading server settings", + "@nextcloudSettingsLoading": {"description": "Accessible progress label while reading server Notes settings."}, + "nextcloudSettingsSaving": "Saving server settings", + "@nextcloudSettingsSaving": {"description": "Accessible progress label while writing server Notes settings."}, + "nextcloudPreviousLocalEdit": "Previous local edit", + "nextcloudUsePreviousEdit": "Use previous edit", + "@nextcloudPreviousLocalEdit": { + "description": "Label for the competing accepted local metadata edit, distinct from server state." + }, + "@nextcloudUsePreviousEdit": { + "description": "Conflict action that keeps the exact previously reviewed local metadata values." + }, + "quickOpen": "Quick Open", + "notesAll": "All notes", + "notesUncategorized": "Uncategorized", + "notesRecovery": "Recovery", + "notesRecentlyEdited": "Recently edited", + "notesOldestEdited": "Oldest edited", + "notesTitleAscending": "Title A–Z", + "notesTitleDescending": "Title Z–A", + "notesMove": "Move to category", + "notesRemoveFavorite": "Remove favorite", + "notesOffline": "Make available offline", + "notesOfflineRemove": "Remove offline requirement", + "notesIndexing": "Indexing notes…", + "notesSearchHelp": "Terms use AND. Use \"quoted phrases\", title: and category:.", + "notesSourceLocation": "Source location", + "notesRecoveryHelp": "Retained local notes and Local History only. Server trash and history are unavailable.", + "notesImport": "Import notes", + "notesExport": "Export notes", + "notesImportDistinct": "Import as a distinct note", + "notesAvailableOffline": "Available offline", + "notesIncomplete": "Incomplete", + "notesSelected": "Selected notes", + "notesNoHistory": "No retained revisions. History may be disabled, expired or cleared.", + "notesTextAvailable": "Text available locally", + "notesLocalVersion": "Local working version", + "notesAttachments": "Attachments", + "notesSkipped": "Skipped", + "notesFailed": "Failed", + "notesCompleted": "Completed", + "notesImportNew": "Start a new import", + "notesImportResume": "Resume interrupted import", + "notesImportAlreadyImported": "Already imported in this operation", + "references": "References", + "referenceIncoming": "Incoming", + "referenceOutgoing": "Outgoing", + "referenceChooseTarget": "Choose link target", + "referenceWholeDocument": "Whole document", + "referenceSourceLocation": "Source location", + "referenceTargetLocation": "Target location", + "referenceResolved": "Resolved", + "referenceMissing": "Missing", + "referenceAmbiguous": "Ambiguous", + "referenceUnavailable": "Unavailable in this context", + "referenceUnsupported": "Unsupported", + "referenceNoPortableId": "This target has no verified portable note URL." } diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 919eb98b..7d1377d3 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -3419,5 +3419,97 @@ "nextcloudPossibleCandidate": "Posible coincidencia con la solicitud saneada; identidad sin confirmar.", "nextcloudSelectCandidate": "Seleccionar una nota del servidor para revisarla", "nextcloudCreateSeparate": "Crear una nota aparte", - "nextcloudCreateSeparateWarning": "La primera solicitud puede haber creado ya una nota en el servidor. Esto crea una nota aparte con su trabajo local conservado. No se eliminará ningún posible duplicado." + "nextcloudCreateSeparateWarning": "La primera solicitud puede haber creado ya una nota en el servidor. Esto crea una nota aparte con su trabajo local conservado. No se eliminará ningún posible duplicado.", + "wsSemanticFormats": "Formato semántico", + "wsUiControl": "Control de interfaz", + "wsFilePath": "Archivo / Ruta", + "wsUiPath": "Ruta de interfaz", + "wsDefinitionList": "Lista de definiciones", + "wsIncludeContent": "Incluir contenido reutilizable", + "wsVariableReference": "Referencia de variable", + "wsConvertList": "Convertir lista en procedimiento", + "wsElementProperties": "Propiedades del elemento", + "wsTopicProperties": "Propiedades del tema", + "wsCollapsible": "Contraíble", + "wsPublishedState": "Estado inicial publicado", + "wsExpanded": "Expandido", + "wsCollapsed": "Contraído", + "wsCollapsedTitle": "Título contraído", + "wsTerm": "Término", + "wsListLayout": "Diseño de la lista", + "wsSyncGroup": "Grupo de sincronización", + "wsTabKey": "Clave de pestaña", + "wsSwitcherLabel": "Etiqueta del selector", + "wsNavigationElements": "Elementos de navegación", + "wsNavigationDepth": "Profundidad de navegación", + "wsAddItem": "Añadir elemento", + "wsStep": "Paso", + "wsPropertyInvalid": "Valor de propiedad no válido", + "wsFilter": "Filtro", + "wsIdentifier": "ID del elemento", + "wsTldr": "Resumen (TLDR)", + "wsFullLayout": "Ancho completo", + "wsMediumLayout": "Columnas iguales", + "wsNarrowLayout": "Término estrecho", + "wsTitle": "Título", + "wsSwitcherKey": "Clave del selector", + "nextcloudServerSettings": "Ajustes de Notes del servidor", + "nextcloudNotesPath": "Ruta de la carpeta de notas", + "nextcloudFileSuffix": "Extensión de las notas nuevas", + "nextcloudServerSettingsExplanation": "Estos ajustes de Notes afectan a otros clientes. Cambiar la ruta no mueve archivos; cambiar la extensión no renombra las notas existentes.", + "nextcloudSettingsSaved": "Ajustes del servidor guardados", + "nextcloudSettingsNormalized": "Guardado con valores normalizados por el servidor", + "nextcloudPendingUpload": "Carga de adjunto pendiente", + "nextcloudServerCheck": "Última comprobación del servidor: {time}", + "nextcloudSettingsLoading": "Cargando ajustes del servidor", + "@nextcloudSettingsLoading": {"description": "Accessible progress label while reading server Notes settings."}, + "nextcloudSettingsSaving": "Guardando ajustes del servidor", + "@nextcloudSettingsSaving": {"description": "Accessible progress label while writing server Notes settings."}, + "nextcloudPreviousLocalEdit": "Edición local anterior", + "nextcloudUsePreviousEdit": "Usar edición anterior", + "quickOpen": "Apertura rápida", + "notesAll": "Todas las notas", + "notesUncategorized": "Sin categoría", + "notesRecovery": "Recuperación", + "notesRecentlyEdited": "Editadas recientemente", + "notesOldestEdited": "Ediciones más antiguas", + "notesTitleAscending": "Título A–Z", + "notesTitleDescending": "Título Z–A", + "notesMove": "Mover a categoría", + "notesRemoveFavorite": "Quitar favorito", + "notesOffline": "Hacer disponible sin conexión", + "notesOfflineRemove": "Quitar conservación sin conexión", + "notesIndexing": "Indexando notas…", + "notesSearchHelp": "Los términos usan Y. Use \"frases entre comillas\", title: y category:.", + "notesSourceLocation": "Ubicación en el código fuente", + "notesRecoveryHelp": "Solo notas locales conservadas e historial local. La papelera y el historial del servidor no están disponibles.", + "notesImport": "Importar notas", + "notesExport": "Exportar notas", + "notesImportDistinct": "Importar como una nota distinta", + "notesAvailableOffline": "Disponible sin conexión", + "notesIncomplete": "Incompleto", + "notesSelected": "Notas seleccionadas", + "notesNoHistory": "No hay revisiones conservadas. El historial puede estar desactivado, caducado o borrado.", + "notesTextAvailable": "Texto disponible localmente", + "notesLocalVersion": "Versión de trabajo local", + "notesAttachments": "Adjuntos", + "notesSkipped": "Omitido", + "notesFailed": "Fallido", + "notesCompleted": "Completado", + "notesImportNew": "Iniciar una nueva importación", + "notesImportResume": "Reanudar importación interrumpida", + "notesImportAlreadyImported": "Ya importado en esta operación", + "references": "Referencias", + "referenceIncoming": "Entrantes", + "referenceOutgoing": "Salientes", + "referenceChooseTarget": "Elegir destino del enlace", + "referenceWholeDocument": "Documento completo", + "referenceSourceLocation": "Ubicación de origen", + "referenceTargetLocation": "Ubicación de destino", + "referenceResolved": "Resuelta", + "referenceMissing": "No encontrada", + "referenceAmbiguous": "Ambigua", + "referenceUnavailable": "No disponible en este contexto", + "referenceUnsupported": "No compatible", + "referenceNoPortableId": "Este destino no tiene una URL portátil de nota verificada." } diff --git a/lib/l10n/app_et.arb b/lib/l10n/app_et.arb index 22fbad6f..e72acaeb 100644 --- a/lib/l10n/app_et.arb +++ b/lib/l10n/app_et.arb @@ -3879,5 +3879,97 @@ "nextcloudPossibleCandidate": "Võimalik puhastatud päringu vaste; identiteet on kinnitamata.", "nextcloudSelectCandidate": "Vali ülevaatamiseks serverimärkus", "nextcloudCreateSeparate": "Loo eraldi märkus", - "nextcloudCreateSeparateWarning": "Esimene päring võis serveris juba märkuse luua. See loob eraldi märkuse koos säilitatud kohaliku tööga. Võimalikke duplikaate ei kustutata." + "nextcloudCreateSeparateWarning": "Esimene päring võis serveris juba märkuse luua. See loob eraldi märkuse koos säilitatud kohaliku tööga. Võimalikke duplikaate ei kustutata.", + "wsSemanticFormats": "Semantiline vormindus", + "wsUiControl": "Kasutajaliidese juhtelement", + "wsFilePath": "Fail / Tee", + "wsUiPath": "Kasutajaliidese tee", + "wsDefinitionList": "Definitsiooniloend", + "wsIncludeContent": "Lisa korduskasutatav sisu", + "wsVariableReference": "Muutuja viide", + "wsConvertList": "Teisenda loend protseduuriks", + "wsElementProperties": "Elemendi omadused", + "wsTopicProperties": "Teema omadused", + "wsCollapsible": "Ahendatav", + "wsPublishedState": "Avaldatud algolek", + "wsExpanded": "Laiendatud", + "wsCollapsed": "Ahendatud", + "wsCollapsedTitle": "Ahendatud pealkiri", + "wsTerm": "Termin", + "wsListLayout": "Loendi paigutus", + "wsSyncGroup": "Sünkroonimisrühm", + "wsTabKey": "Vahelehe võti", + "wsSwitcherLabel": "Lüliti silt", + "wsNavigationElements": "Navigeerimise elemendid", + "wsNavigationDepth": "Navigeerimise sügavus", + "wsAddItem": "Lisa element", + "wsStep": "Samm", + "wsPropertyInvalid": "Vigane omaduse väärtus", + "wsFilter": "Sisufilter", + "wsIdentifier": "Elemendi ID", + "wsTldr": "Kokkuvõte (TLDR)", + "wsFullLayout": "Täislaius", + "wsMediumLayout": "Võrdsed veerud", + "wsNarrowLayout": "Kitsas terminiveerg", + "wsTitle": "Pealkiri", + "wsSwitcherKey": "Valikulüliti võti", + "nextcloudServerSettings": "Serveri Notesi seaded", + "nextcloudNotesPath": "Märkmete kausta tee", + "nextcloudFileSuffix": "Uute märkmete faililaiend", + "nextcloudServerSettingsExplanation": "Need Notesi seaded mõjutavad teisi kliente. Tee muutmine ei teisalda faile; laiendi muutmine ei nimeta olemasolevaid märkmeid ümber.", + "nextcloudSettingsSaved": "Serveri seaded salvestatud", + "nextcloudSettingsNormalized": "Salvestatud serveri normaliseeritud väärtustega", + "nextcloudPendingUpload": "Manuse üleslaadimine ootel", + "nextcloudServerCheck": "Viimane serverikontroll: {time}", + "nextcloudSettingsLoading": "Serveri seadete laadimine", + "@nextcloudSettingsLoading": {"description": "Accessible progress label while reading server Notes settings."}, + "nextcloudSettingsSaving": "Serveri seadete salvestamine", + "@nextcloudSettingsSaving": {"description": "Accessible progress label while writing server Notes settings."}, + "nextcloudPreviousLocalEdit": "Eelmine kohalik muudatus", + "nextcloudUsePreviousEdit": "Kasuta eelmist muudatust", + "quickOpen": "Kiiravamine", + "notesAll": "Kõik märkmed", + "notesUncategorized": "Kategooriata", + "notesRecovery": "Taastamine", + "notesRecentlyEdited": "Hiljuti muudetud", + "notesOldestEdited": "Vanim muutmine", + "notesTitleAscending": "Pealkiri A–Z", + "notesTitleDescending": "Pealkiri Z–A", + "notesMove": "Teisalda kategooriasse", + "notesRemoveFavorite": "Eemalda lemmik", + "notesOffline": "Tee võrguta kättesaadavaks", + "notesOfflineRemove": "Eemalda võrguta säilitamine", + "notesIndexing": "Märkmete indekseerimine…", + "notesSearchHelp": "Mõisted ühendatakse JA abil. Kasuta \"tsiteeritud fraase\", title: ja category:.", + "notesSourceLocation": "Lähtekoht", + "notesRecoveryHelp": "Ainult säilitatud kohalikud märkmed ja kohalik ajalugu. Serveri prügikast ja ajalugu pole saadaval.", + "notesImport": "Impordi märkmed", + "notesExport": "Ekspordi märkmed", + "notesImportDistinct": "Impordi eraldi märkmena", + "notesAvailableOffline": "Võrguta saadaval", + "notesIncomplete": "Mittetäielik", + "notesSelected": "Valitud märkmed", + "notesNoHistory": "Säilitatud versioone pole. Ajalugu võib olla keelatud, aegunud või kustutatud.", + "notesTextAvailable": "Tekst on kohalikult saadaval", + "notesLocalVersion": "Kohalik tööversioon", + "notesAttachments": "Manused", + "notesSkipped": "Vahele jäetud", + "notesFailed": "Ebaõnnestus", + "notesCompleted": "Lõpetatud", + "notesImportNew": "Alusta uut importimist", + "notesImportResume": "Jätka katkestatud importimist", + "notesImportAlreadyImported": "Selles toimingus juba imporditud", + "references": "Viited", + "referenceIncoming": "Sissetulevad", + "referenceOutgoing": "Väljaminevad", + "referenceChooseTarget": "Vali lingi siht", + "referenceWholeDocument": "Kogu dokument", + "referenceSourceLocation": "Allika asukoht", + "referenceTargetLocation": "Sihtkoha asukoht", + "referenceResolved": "Lahendatud", + "referenceMissing": "Puudub", + "referenceAmbiguous": "Mitmetähenduslik", + "referenceUnavailable": "Selles kontekstis pole saadaval", + "referenceUnsupported": "Pole toetatud", + "referenceNoPortableId": "Sellel sihtkohal pole kinnitatud teisaldatavat märkme URL-i." } diff --git a/lib/l10n/app_fa.arb b/lib/l10n/app_fa.arb index b880d7cc..27a29f1e 100644 --- a/lib/l10n/app_fa.arb +++ b/lib/l10n/app_fa.arb @@ -3461,5 +3461,97 @@ "nextcloudPossibleCandidate": "تطابق احتمالی درخواست پاک‌سازی‌شده؛ هویت تأیید نشده است.", "nextcloudSelectCandidate": "یک یادداشت سرور را برای بررسی انتخاب کنید", "nextcloudCreateSeparate": "ایجاد یک یادداشت جداگانه", - "nextcloudCreateSeparateWarning": "ممکن است درخواست نخست قبلاً یک یادداشت سرور ایجاد کرده باشد. این کار یک یادداشت جداگانه با کار محلی حفظ‌شدهٔ شما ایجاد می‌کند. هیچ یادداشت مشکوک به تکراری بودن حذف نمی‌شود." + "nextcloudCreateSeparateWarning": "ممکن است درخواست نخست قبلاً یک یادداشت سرور ایجاد کرده باشد. این کار یک یادداشت جداگانه با کار محلی حفظ‌شدهٔ شما ایجاد می‌کند. هیچ یادداشت مشکوک به تکراری بودن حذف نمی‌شود.", + "wsSemanticFormats": "قالب‌بندی معنایی", + "wsUiControl": "کنترل رابط کاربری", + "wsFilePath": "فایل / مسیر", + "wsUiPath": "مسیر رابط کاربری", + "wsDefinitionList": "فهرست تعریف‌ها", + "wsIncludeContent": "درج محتوای قابل استفاده مجدد", + "wsVariableReference": "ارجاع به متغیر", + "wsConvertList": "تبدیل فهرست به رویه", + "wsElementProperties": "ویژگی‌های عنصر", + "wsTopicProperties": "ویژگی‌های موضوع", + "wsCollapsible": "قابل جمع شدن", + "wsPublishedState": "حالت اولیه هنگام انتشار", + "wsExpanded": "باز", + "wsCollapsed": "جمع شده", + "wsCollapsedTitle": "عنوان در حالت جمع شده", + "wsTerm": "اصطلاح", + "wsListLayout": "چیدمان فهرست", + "wsSyncGroup": "گروه همگام‌سازی", + "wsTabKey": "کلید زبانه", + "wsSwitcherLabel": "برچسب تغییر‌دهنده", + "wsNavigationElements": "عناصر پیمایش", + "wsNavigationDepth": "عمق پیمایش", + "wsAddItem": "افزودن مورد", + "wsStep": "گام", + "wsPropertyInvalid": "مقدار ویژگی نامعتبر", + "wsFilter": "فیلتر", + "wsIdentifier": "شناسه عنصر", + "wsTldr": "خلاصه (TLDR)", + "wsFullLayout": "عرض کامل", + "wsMediumLayout": "ستون‌های برابر", + "wsNarrowLayout": "ستون باریک اصطلاح", + "wsTitle": "عنوان", + "wsSwitcherKey": "کلید انتخابگر", + "nextcloudServerSettings": "تنظیمات Notes سرور", + "nextcloudNotesPath": "مسیر پوشهٔ یادداشت‌ها", + "nextcloudFileSuffix": "پسوند فایل یادداشت‌های جدید", + "nextcloudServerSettingsExplanation": "این تنظیمات Notes روی کارخواه‌های دیگر هم اثر می‌گذارد. تغییر مسیر فایل‌ها را جابه‌جا نمی‌کند؛ تغییر پسوند نام یادداشت‌های موجود را تغییر نمی‌دهد.", + "nextcloudSettingsSaved": "تنظیمات سرور ذخیره شد", + "nextcloudSettingsNormalized": "با مقادیر نرمال‌شدهٔ سرور ذخیره شد", + "nextcloudPendingUpload": "بارگذاری پیوست در انتظار است", + "nextcloudServerCheck": "آخرین بررسی سرور: {time}", + "nextcloudSettingsLoading": "در حال بارگیری تنظیمات سرور", + "@nextcloudSettingsLoading": {"description": "Accessible progress label while reading server Notes settings."}, + "nextcloudSettingsSaving": "در حال ذخیره تنظیمات سرور", + "@nextcloudSettingsSaving": {"description": "Accessible progress label while writing server Notes settings."}, + "nextcloudPreviousLocalEdit": "ویرایش محلی قبلی", + "nextcloudUsePreviousEdit": "استفاده از ویرایش قبلی", + "quickOpen": "باز کردن سریع", + "notesAll": "همه یادداشت‌ها", + "notesUncategorized": "بدون دسته", + "notesRecovery": "بازیابی", + "notesRecentlyEdited": "تازه ویرایش‌شده", + "notesOldestEdited": "قدیمی‌ترین ویرایش", + "notesTitleAscending": "عنوان صعودی", + "notesTitleDescending": "عنوان نزولی", + "notesMove": "انتقال به دسته", + "notesRemoveFavorite": "حذف از علاقه‌مندی‌ها", + "notesOffline": "در دسترس کردن آفلاین", + "notesOfflineRemove": "حذف نگهداری آفلاین", + "notesIndexing": "در حال نمایه‌سازی یادداشت‌ها…", + "notesSearchHelp": "واژه‌ها با AND ترکیب می‌شوند. از \"عبارت نقل‌شده\" و title: و category: استفاده کنید.", + "notesSourceLocation": "موقعیت در منبع", + "notesRecoveryHelp": "فقط یادداشت‌های محلی نگهداری‌شده و تاریخچه محلی. زباله‌دان و تاریخچه سرور در دسترس نیست.", + "notesImport": "وارد کردن یادداشت‌ها", + "notesExport": "صادر کردن یادداشت‌ها", + "notesImportDistinct": "وارد کردن به عنوان یادداشت جدا", + "notesAvailableOffline": "در دسترس آفلاین", + "notesIncomplete": "ناقص", + "notesSelected": "یادداشت‌های انتخاب‌شده", + "notesNoHistory": "نسخه نگهداری‌شده‌ای نیست. تاریخچه ممکن است غیرفعال، منقضی یا پاک شده باشد.", + "notesTextAvailable": "متن به صورت محلی در دسترس است", + "notesLocalVersion": "نسخهٔ کاری محلی", + "notesAttachments": "پیوست‌ها", + "notesSkipped": "رد شد", + "notesFailed": "ناموفق", + "notesCompleted": "تکمیل شد", + "notesImportNew": "شروع وارد کردن جدید", + "notesImportResume": "ادامه وارد کردن متوقف‌شده", + "notesImportAlreadyImported": "قبلاً در این عملیات وارد شده است", + "references": "ارجاع‌ها", + "referenceIncoming": "ورودی", + "referenceOutgoing": "خروجی", + "referenceChooseTarget": "انتخاب مقصد پیوند", + "referenceWholeDocument": "کل سند", + "referenceSourceLocation": "مکان منبع", + "referenceTargetLocation": "مکان مقصد", + "referenceResolved": "حل‌شده", + "referenceMissing": "یافت نشد", + "referenceAmbiguous": "مبهم", + "referenceUnavailable": "در این زمینه در دسترس نیست", + "referenceUnsupported": "پشتیبانی نمی‌شود", + "referenceNoPortableId": "این مقصد نشانی قابل‌انتقال و تأییدشده‌ای برای یادداشت ندارد." } diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 608257f8..7caaf1c8 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -3419,5 +3419,97 @@ "nextcloudPossibleCandidate": "Correspondance possible après normalisation ; identité non confirmée.", "nextcloudSelectCandidate": "Sélectionner une note du serveur à examiner", "nextcloudCreateSeparate": "Créer une note distincte", - "nextcloudCreateSeparateWarning": "La première requête a peut-être déjà créé une note sur le serveur. Ceci crée une note distincte avec votre travail local conservé. Aucun doublon supposé ne sera supprimé." + "nextcloudCreateSeparateWarning": "La première requête a peut-être déjà créé une note sur le serveur. Ceci crée une note distincte avec votre travail local conservé. Aucun doublon supposé ne sera supprimé.", + "wsSemanticFormats": "Mise en forme sémantique", + "wsUiControl": "Contrôle d’interface", + "wsFilePath": "Fichier / Chemin", + "wsUiPath": "Chemin d’interface", + "wsDefinitionList": "Liste de définitions", + "wsIncludeContent": "Inclure du contenu réutilisable", + "wsVariableReference": "Référence de variable", + "wsConvertList": "Convertir la liste en procédure", + "wsElementProperties": "Propriétés de l’élément", + "wsTopicProperties": "Propriétés du sujet", + "wsCollapsible": "Repliable", + "wsPublishedState": "État initial publié", + "wsExpanded": "Développé", + "wsCollapsed": "Replié", + "wsCollapsedTitle": "Titre replié", + "wsTerm": "Terme", + "wsListLayout": "Disposition de la liste", + "wsSyncGroup": "Groupe de synchronisation", + "wsTabKey": "Clé de l’onglet", + "wsSwitcherLabel": "Libellé du sélecteur", + "wsNavigationElements": "Éléments de navigation", + "wsNavigationDepth": "Profondeur de navigation", + "wsAddItem": "Ajouter un élément", + "wsStep": "Étape", + "wsPropertyInvalid": "Valeur de propriété non valide", + "wsFilter": "Filtre", + "wsIdentifier": "ID de l’élément", + "wsTldr": "Résumé (TLDR)", + "wsFullLayout": "Pleine largeur", + "wsMediumLayout": "Colonnes égales", + "wsNarrowLayout": "Terme étroit", + "wsTitle": "Titre", + "wsSwitcherKey": "Clé du sélecteur", + "nextcloudServerSettings": "Paramètres Notes du serveur", + "nextcloudNotesPath": "Chemin du dossier de notes", + "nextcloudFileSuffix": "Extension des nouvelles notes", + "nextcloudServerSettingsExplanation": "Ces paramètres Notes du serveur affectent les autres clients. Changer le chemin ne déplace pas les fichiers ; changer l’extension ne renomme pas les notes existantes.", + "nextcloudSettingsSaved": "Paramètres serveur enregistrés", + "nextcloudSettingsNormalized": "Enregistré avec les valeurs normalisées par le serveur", + "nextcloudPendingUpload": "Envoi de pièce jointe en attente", + "nextcloudServerCheck": "Dernière vérification du serveur : {time}", + "nextcloudSettingsLoading": "Chargement des paramètres du serveur", + "@nextcloudSettingsLoading": {"description": "Accessible progress label while reading server Notes settings."}, + "nextcloudSettingsSaving": "Enregistrement des paramètres du serveur", + "@nextcloudSettingsSaving": {"description": "Accessible progress label while writing server Notes settings."}, + "nextcloudPreviousLocalEdit": "Modification locale précédente", + "nextcloudUsePreviousEdit": "Utiliser la modification précédente", + "quickOpen": "Ouverture rapide", + "notesAll": "Toutes les notes", + "notesUncategorized": "Sans catégorie", + "notesRecovery": "Récupération", + "notesRecentlyEdited": "Modifiées récemment", + "notesOldestEdited": "Modifiées les plus anciennes", + "notesTitleAscending": "Titre A–Z", + "notesTitleDescending": "Titre Z–A", + "notesMove": "Déplacer vers une catégorie", + "notesRemoveFavorite": "Retirer des favoris", + "notesOffline": "Rendre disponible hors ligne", + "notesOfflineRemove": "Retirer la conservation hors ligne", + "notesIndexing": "Indexation des notes…", + "notesSearchHelp": "Les termes utilisent ET. Utilisez \"des expressions\", title: et category:.", + "notesSourceLocation": "Emplacement source", + "notesRecoveryHelp": "Notes locales conservées et historique local uniquement. La corbeille et l’historique du serveur sont indisponibles.", + "notesImport": "Importer des notes", + "notesExport": "Exporter des notes", + "notesImportDistinct": "Importer comme une note distincte", + "notesAvailableOffline": "Disponible hors ligne", + "notesIncomplete": "Incomplet", + "notesSelected": "Notes sélectionnées", + "notesNoHistory": "Aucune révision conservée. L’historique peut être désactivé, expiré ou effacé.", + "notesTextAvailable": "Texte disponible localement", + "notesLocalVersion": "Version de travail locale", + "notesAttachments": "Pièces jointes", + "notesSkipped": "Ignoré", + "notesFailed": "Échec", + "notesCompleted": "Terminé", + "notesImportNew": "Démarrer une nouvelle importation", + "notesImportResume": "Reprendre l’importation interrompue", + "notesImportAlreadyImported": "Déjà importé dans cette opération", + "references": "Références", + "referenceIncoming": "Entrantes", + "referenceOutgoing": "Sortantes", + "referenceChooseTarget": "Choisir la cible du lien", + "referenceWholeDocument": "Document entier", + "referenceSourceLocation": "Emplacement source", + "referenceTargetLocation": "Emplacement cible", + "referenceResolved": "Résolue", + "referenceMissing": "Manquante", + "referenceAmbiguous": "Ambiguë", + "referenceUnavailable": "Indisponible dans ce contexte", + "referenceUnsupported": "Non prise en charge", + "referenceNoPortableId": "Cette cible ne possède pas d’URL de note portable vérifiée." } diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index b99755a9..8c6c18d6 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -3419,5 +3419,97 @@ "nextcloudPossibleCandidate": "संशोधित अनुरोध से संभावित मेल; पहचान की पुष्टि नहीं हुई है।", "nextcloudSelectCandidate": "समीक्षा के लिए सर्वर का नोट चुनें", "nextcloudCreateSeparate": "अलग नोट बनाएँ", - "nextcloudCreateSeparateWarning": "पहले अनुरोध ने सर्वर पर पहले ही नोट बना दिया हो सकता है। यह आपके सुरक्षित स्थानीय काम के साथ अलग नोट बनाता है। किसी संभावित प्रतिलिपि को हटाया नहीं जाएगा।" + "nextcloudCreateSeparateWarning": "पहले अनुरोध ने सर्वर पर पहले ही नोट बना दिया हो सकता है। यह आपके सुरक्षित स्थानीय काम के साथ अलग नोट बनाता है। किसी संभावित प्रतिलिपि को हटाया नहीं जाएगा।", + "wsSemanticFormats": "अर्थपूर्ण स्वरूपण", + "wsUiControl": "यूआई नियंत्रण", + "wsFilePath": "फ़ाइल / पथ", + "wsUiPath": "यूआई पथ", + "wsDefinitionList": "परिभाषा सूची", + "wsIncludeContent": "पुनः उपयोग योग्य सामग्री शामिल करें", + "wsVariableReference": "चर संदर्भ", + "wsConvertList": "सूची को प्रक्रिया में बदलें", + "wsElementProperties": "तत्व के गुण", + "wsTopicProperties": "विषय के गुण", + "wsCollapsible": "समेटने योग्य", + "wsPublishedState": "प्रकाशित आरंभिक स्थिति", + "wsExpanded": "विस्तृत", + "wsCollapsed": "संक्षिप्त", + "wsCollapsedTitle": "संक्षिप्त शीर्षक", + "wsTerm": "शब्द", + "wsListLayout": "सूची लेआउट", + "wsSyncGroup": "सिंक समूह", + "wsTabKey": "टैब कुंजी", + "wsSwitcherLabel": "स्विचर लेबल", + "wsNavigationElements": "नेविगेशन तत्व", + "wsNavigationDepth": "नेविगेशन गहराई", + "wsAddItem": "आइटम जोड़ें", + "wsStep": "चरण", + "wsPropertyInvalid": "अमान्य गुण मान", + "wsFilter": "फ़िल्टर", + "wsIdentifier": "तत्व आईडी", + "wsTldr": "सारांश (TLDR)", + "wsFullLayout": "पूरी चौड़ाई", + "wsMediumLayout": "समान स्तंभ", + "wsNarrowLayout": "संकीर्ण शब्द स्तंभ", + "wsTitle": "शीर्षक", + "wsSwitcherKey": "स्विचर कुंजी", + "nextcloudServerSettings": "सर्वर Notes सेटिंग्स", + "nextcloudNotesPath": "नोट फ़ोल्डर का पथ", + "nextcloudFileSuffix": "नए नोट की फ़ाइल का प्रत्यय", + "nextcloudServerSettingsExplanation": "ये सर्वर Notes सेटिंग्स दूसरे क्लाइंट को भी प्रभावित करती हैं। पथ बदलने से फ़ाइलें स्थानांतरित नहीं होतीं; प्रत्यय बदलने से मौजूदा नोट का नाम नहीं बदलता।", + "nextcloudSettingsSaved": "सर्वर सेटिंग्स सहेजी गईं", + "nextcloudSettingsNormalized": "सर्वर द्वारा सामान्यीकृत मानों के साथ सहेजा गया", + "nextcloudPendingUpload": "अटैचमेंट अपलोड लंबित है", + "nextcloudServerCheck": "सर्वर की अंतिम जाँच: {time}", + "nextcloudSettingsLoading": "सर्वर सेटिंग लोड हो रही हैं", + "@nextcloudSettingsLoading": {"description": "Accessible progress label while reading server Notes settings."}, + "nextcloudSettingsSaving": "सर्वर सेटिंग सहेजी जा रही हैं", + "@nextcloudSettingsSaving": {"description": "Accessible progress label while writing server Notes settings."}, + "nextcloudPreviousLocalEdit": "पिछला स्थानीय संपादन", + "nextcloudUsePreviousEdit": "पिछला संपादन इस्तेमाल करें", + "quickOpen": "त्वरित खोलें", + "notesAll": "सभी नोट्स", + "notesUncategorized": "बिना श्रेणी", + "notesRecovery": "पुनर्प्राप्ति", + "notesRecentlyEdited": "हाल में संपादित", + "notesOldestEdited": "सबसे पुराना संपादन", + "notesTitleAscending": "शीर्षक आरोही", + "notesTitleDescending": "शीर्षक अवरोही", + "notesMove": "श्रेणी में ले जाएँ", + "notesRemoveFavorite": "पसंदीदा हटाएँ", + "notesOffline": "ऑफ़लाइन उपलब्ध करें", + "notesOfflineRemove": "ऑफ़लाइन संरक्षण हटाएँ", + "notesIndexing": "नोट्स की अनुक्रमणिका बन रही है…", + "notesSearchHelp": "शब्द AND से जुड़ते हैं। \"उद्धृत वाक्यांश\", title: और category: का उपयोग करें।", + "notesSourceLocation": "स्रोत स्थान", + "notesRecoveryHelp": "केवल संरक्षित स्थानीय नोट्स और स्थानीय इतिहास। सर्वर का कचरा और इतिहास उपलब्ध नहीं है।", + "notesImport": "नोट्स आयात करें", + "notesExport": "नोट्स निर्यात करें", + "notesImportDistinct": "अलग नोट के रूप में आयात करें", + "notesAvailableOffline": "ऑफ़लाइन उपलब्ध", + "notesIncomplete": "अपूर्ण", + "notesSelected": "चुने गए नोट्स", + "notesNoHistory": "कोई संरक्षित संशोधन नहीं। इतिहास बंद, समाप्त या साफ़ किया गया हो सकता है।", + "notesTextAvailable": "पाठ स्थानीय रूप से उपलब्ध है", + "notesLocalVersion": "स्थानीय कार्य संस्करण", + "notesAttachments": "अटैचमेंट", + "notesSkipped": "छोड़ा गया", + "notesFailed": "विफल", + "notesCompleted": "पूर्ण", + "notesImportNew": "नया आयात शुरू करें", + "notesImportResume": "बाधित आयात फिर से शुरू करें", + "notesImportAlreadyImported": "इस प्रक्रिया में पहले ही आयात किया गया", + "references": "संदर्भ", + "referenceIncoming": "आने वाले", + "referenceOutgoing": "जाने वाले", + "referenceChooseTarget": "लिंक लक्ष्य चुनें", + "referenceWholeDocument": "पूरा दस्तावेज़", + "referenceSourceLocation": "स्रोत स्थान", + "referenceTargetLocation": "लक्ष्य स्थान", + "referenceResolved": "हल हुआ", + "referenceMissing": "गुम", + "referenceAmbiguous": "अस्पष्ट", + "referenceUnavailable": "इस संदर्भ में अनुपलब्ध", + "referenceUnsupported": "असमर्थित", + "referenceNoPortableId": "इस लक्ष्य का सत्यापित पोर्टेबल नोट URL नहीं है." } diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 4b25ffd3..6a6472b1 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -4958,5 +4958,97 @@ "nextcloudPossibleCandidate": "Kemungkinan cocok setelah permintaan disanitasi; identitas belum dikonfirmasi.", "nextcloudSelectCandidate": "Pilih catatan server untuk ditinjau", "nextcloudCreateSeparate": "Buat catatan terpisah", - "nextcloudCreateSeparateWarning": "Permintaan pertama mungkin sudah membuat catatan server. Ini membuat catatan terpisah dengan pekerjaan lokal yang dipertahankan. Tidak ada dugaan duplikat yang akan dihapus." + "nextcloudCreateSeparateWarning": "Permintaan pertama mungkin sudah membuat catatan server. Ini membuat catatan terpisah dengan pekerjaan lokal yang dipertahankan. Tidak ada dugaan duplikat yang akan dihapus.", + "wsSemanticFormats": "Pemformatan semantik", + "wsUiControl": "Kontrol antarmuka", + "wsFilePath": "Berkas / Jalur", + "wsUiPath": "Jalur antarmuka", + "wsDefinitionList": "Daftar definisi", + "wsIncludeContent": "Sertakan konten yang dapat digunakan ulang", + "wsVariableReference": "Referensi variabel", + "wsConvertList": "Ubah daftar menjadi prosedur", + "wsElementProperties": "Properti elemen", + "wsTopicProperties": "Properti topik", + "wsCollapsible": "Dapat diciutkan", + "wsPublishedState": "Status awal publikasi", + "wsExpanded": "Diperluas", + "wsCollapsed": "Diciutkan", + "wsCollapsedTitle": "Judul saat diciutkan", + "wsTerm": "Istilah", + "wsListLayout": "Tata letak daftar", + "wsSyncGroup": "Grup sinkronisasi", + "wsTabKey": "Kunci tab", + "wsSwitcherLabel": "Label pengalih", + "wsNavigationElements": "Elemen navigasi", + "wsNavigationDepth": "Kedalaman navigasi", + "wsAddItem": "Tambah item", + "wsStep": "Langkah", + "wsPropertyInvalid": "Nilai properti tidak valid", + "wsFilter": "Filter konten", + "wsIdentifier": "ID elemen", + "wsTldr": "Ringkasan (TLDR)", + "wsFullLayout": "Lebar penuh", + "wsMediumLayout": "Kolom seimbang", + "wsNarrowLayout": "Kolom istilah sempit", + "wsTitle": "Judul", + "wsSwitcherKey": "Kunci pemilih", + "nextcloudServerSettings": "Pengaturan Notes server", + "nextcloudNotesPath": "Jalur folder catatan", + "nextcloudFileSuffix": "Ekstensi berkas catatan baru", + "nextcloudServerSettingsExplanation": "Pengaturan Notes server ini memengaruhi klien lain. Mengubah jalur tidak memindahkan berkas; mengubah ekstensi tidak mengganti nama catatan yang ada.", + "nextcloudSettingsSaved": "Pengaturan server disimpan", + "nextcloudSettingsNormalized": "Disimpan dengan nilai yang dinormalisasi server", + "nextcloudPendingUpload": "Unggahan lampiran tertunda", + "nextcloudServerCheck": "Pemeriksaan server terakhir: {time}", + "nextcloudSettingsLoading": "Memuat pengaturan server", + "@nextcloudSettingsLoading": {"description": "Accessible progress label while reading server Notes settings."}, + "nextcloudSettingsSaving": "Menyimpan pengaturan server", + "@nextcloudSettingsSaving": {"description": "Accessible progress label while writing server Notes settings."}, + "nextcloudPreviousLocalEdit": "Edit lokal sebelumnya", + "nextcloudUsePreviousEdit": "Gunakan edit sebelumnya", + "quickOpen": "Buka cepat", + "notesAll": "Semua catatan", + "notesUncategorized": "Tanpa kategori", + "notesRecovery": "Pemulihan", + "notesRecentlyEdited": "Baru diedit", + "notesOldestEdited": "Edit terlama", + "notesTitleAscending": "Judul A–Z", + "notesTitleDescending": "Judul Z–A", + "notesMove": "Pindah ke kategori", + "notesRemoveFavorite": "Hapus favorit", + "notesOffline": "Sediakan secara luring", + "notesOfflineRemove": "Hapus penyimpanan luring", + "notesIndexing": "Mengindeks catatan…", + "notesSearchHelp": "Istilah digabung dengan DAN. Gunakan \"frasa kutipan\", title: dan category:.", + "notesSourceLocation": "Lokasi sumber", + "notesRecoveryHelp": "Hanya catatan lokal tersimpan dan riwayat lokal. Sampah dan riwayat server tidak tersedia.", + "notesImport": "Impor catatan", + "notesExport": "Ekspor catatan", + "notesImportDistinct": "Impor sebagai catatan terpisah", + "notesAvailableOffline": "Tersedia secara luring", + "notesIncomplete": "Tidak lengkap", + "notesSelected": "Catatan terpilih", + "notesNoHistory": "Tidak ada revisi tersimpan. Riwayat mungkin dinonaktifkan, kedaluwarsa atau dihapus.", + "notesTextAvailable": "Teks tersedia secara lokal", + "notesLocalVersion": "Versi kerja lokal", + "notesAttachments": "Lampiran", + "notesSkipped": "Dilewati", + "notesFailed": "Gagal", + "notesCompleted": "Selesai", + "notesImportNew": "Mulai impor baru", + "notesImportResume": "Lanjutkan impor yang terputus", + "notesImportAlreadyImported": "Sudah diimpor dalam operasi ini", + "references": "Referensi", + "referenceIncoming": "Masuk", + "referenceOutgoing": "Keluar", + "referenceChooseTarget": "Pilih tujuan tautan", + "referenceWholeDocument": "Seluruh dokumen", + "referenceSourceLocation": "Lokasi sumber", + "referenceTargetLocation": "Lokasi tujuan", + "referenceResolved": "Terselesaikan", + "referenceMissing": "Tidak ditemukan", + "referenceAmbiguous": "Ambigu", + "referenceUnavailable": "Tidak tersedia dalam konteks ini", + "referenceUnsupported": "Tidak didukung", + "referenceNoPortableId": "Tujuan ini tidak memiliki URL catatan portabel yang terverifikasi." } diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 74a442bb..97947e9d 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -3419,5 +3419,97 @@ "nextcloudPossibleCandidate": "Possibile corrispondenza dopo la normalizzazione; identità non confermata.", "nextcloudSelectCandidate": "Seleziona una nota del server da esaminare", "nextcloudCreateSeparate": "Crea una nota separata", - "nextcloudCreateSeparateWarning": "La prima richiesta potrebbe aver già creato una nota sul server. Questa azione crea una nota separata con il lavoro locale conservato. Nessun presunto duplicato verrà eliminato." + "nextcloudCreateSeparateWarning": "La prima richiesta potrebbe aver già creato una nota sul server. Questa azione crea una nota separata con il lavoro locale conservato. Nessun presunto duplicato verrà eliminato.", + "wsSemanticFormats": "Formattazione semantica", + "wsUiControl": "Controllo dell’interfaccia", + "wsFilePath": "File / Percorso", + "wsUiPath": "Percorso dell’interfaccia", + "wsDefinitionList": "Elenco di definizioni", + "wsIncludeContent": "Includi contenuto riutilizzabile", + "wsVariableReference": "Riferimento a variabile", + "wsConvertList": "Converti elenco in procedura", + "wsElementProperties": "Proprietà dell’elemento", + "wsTopicProperties": "Proprietà dell’argomento", + "wsCollapsible": "Comprimibile", + "wsPublishedState": "Stato iniziale pubblicato", + "wsExpanded": "Espanso", + "wsCollapsed": "Compresso", + "wsCollapsedTitle": "Titolo compresso", + "wsTerm": "Termine", + "wsListLayout": "Disposizione dell’elenco", + "wsSyncGroup": "Gruppo di sincronizzazione", + "wsTabKey": "Chiave della scheda", + "wsSwitcherLabel": "Etichetta del selettore", + "wsNavigationElements": "Elementi di navigazione", + "wsNavigationDepth": "Profondità di navigazione", + "wsAddItem": "Aggiungi elemento", + "wsStep": "Passaggio", + "wsPropertyInvalid": "Valore della proprietà non valido", + "wsFilter": "Filtro", + "wsIdentifier": "ID elemento", + "wsTldr": "Riepilogo (TLDR)", + "wsFullLayout": "Larghezza piena", + "wsMediumLayout": "Colonne uguali", + "wsNarrowLayout": "Termine stretto", + "wsTitle": "Titolo", + "wsSwitcherKey": "Chiave del selettore", + "nextcloudServerSettings": "Impostazioni Notes del server", + "nextcloudNotesPath": "Percorso della cartella delle note", + "nextcloudFileSuffix": "Estensione delle nuove note", + "nextcloudServerSettingsExplanation": "Queste impostazioni Notes influiscono sugli altri client. Cambiare il percorso non sposta i file; cambiare l’estensione non rinomina le note esistenti.", + "nextcloudSettingsSaved": "Impostazioni del server salvate", + "nextcloudSettingsNormalized": "Salvato con i valori normalizzati dal server", + "nextcloudPendingUpload": "Caricamento allegato in attesa", + "nextcloudServerCheck": "Ultimo controllo del server: {time}", + "nextcloudSettingsLoading": "Caricamento delle impostazioni del server", + "@nextcloudSettingsLoading": {"description": "Accessible progress label while reading server Notes settings."}, + "nextcloudSettingsSaving": "Salvataggio delle impostazioni del server", + "@nextcloudSettingsSaving": {"description": "Accessible progress label while writing server Notes settings."}, + "nextcloudPreviousLocalEdit": "Modifica locale precedente", + "nextcloudUsePreviousEdit": "Usa la modifica precedente", + "quickOpen": "Apertura rapida", + "notesAll": "Tutte le note", + "notesUncategorized": "Senza categoria", + "notesRecovery": "Recupero", + "notesRecentlyEdited": "Modificate di recente", + "notesOldestEdited": "Modifiche più vecchie", + "notesTitleAscending": "Titolo A–Z", + "notesTitleDescending": "Titolo Z–A", + "notesMove": "Sposta nella categoria", + "notesRemoveFavorite": "Rimuovi preferito", + "notesOffline": "Rendi disponibile offline", + "notesOfflineRemove": "Rimuovi conservazione offline", + "notesIndexing": "Indicizzazione delle note…", + "notesSearchHelp": "I termini usano E. Usa \"frasi tra virgolette\", title: e category:.", + "notesSourceLocation": "Posizione nel sorgente", + "notesRecoveryHelp": "Solo note locali conservate e cronologia locale. Cestino e cronologia del server non disponibili.", + "notesImport": "Importa note", + "notesExport": "Esporta note", + "notesImportDistinct": "Importa come nota distinta", + "notesAvailableOffline": "Disponibile offline", + "notesIncomplete": "Incompleto", + "notesSelected": "Note selezionate", + "notesNoHistory": "Nessuna revisione conservata. La cronologia può essere disabilitata, scaduta o cancellata.", + "notesTextAvailable": "Testo disponibile localmente", + "notesLocalVersion": "Versione di lavoro locale", + "notesAttachments": "Allegati", + "notesSkipped": "Saltato", + "notesFailed": "Non riuscito", + "notesCompleted": "Completato", + "notesImportNew": "Avvia una nuova importazione", + "notesImportResume": "Riprendi importazione interrotta", + "notesImportAlreadyImported": "Già importato in questa operazione", + "references": "Riferimenti", + "referenceIncoming": "In entrata", + "referenceOutgoing": "In uscita", + "referenceChooseTarget": "Scegli destinazione del collegamento", + "referenceWholeDocument": "Intero documento", + "referenceSourceLocation": "Posizione di origine", + "referenceTargetLocation": "Posizione di destinazione", + "referenceResolved": "Risolto", + "referenceMissing": "Mancante", + "referenceAmbiguous": "Ambiguo", + "referenceUnavailable": "Non disponibile in questo contesto", + "referenceUnsupported": "Non supportato", + "referenceNoPortableId": "Questa destinazione non ha un URL portabile della nota verificato." } diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index b923ea74..56b3b814 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -4958,5 +4958,97 @@ "nextcloudPossibleCandidate": "正規化されたリクエストと一致する可能性あり。識別情報は未確認です。", "nextcloudSelectCandidate": "確認するサーバーノートを選択", "nextcloudCreateSeparate": "別のノートを作成", - "nextcloudCreateSeparateWarning": "最初のリクエストですでにサーバーノートが作成された可能性があります。保持したローカル作業から別のノートを作成します。重複の疑いがあるノートは削除されません。" + "nextcloudCreateSeparateWarning": "最初のリクエストですでにサーバーノートが作成された可能性があります。保持したローカル作業から別のノートを作成します。重複の疑いがあるノートは削除されません。", + "wsSemanticFormats": "意味に基づく書式", + "wsUiControl": "UI コントロール", + "wsFilePath": "ファイル / パス", + "wsUiPath": "UI パス", + "wsDefinitionList": "定義リスト", + "wsIncludeContent": "再利用可能なコンテンツを含める", + "wsVariableReference": "変数参照", + "wsConvertList": "リストを手順に変換", + "wsElementProperties": "要素のプロパティ", + "wsTopicProperties": "トピックのプロパティ", + "wsCollapsible": "折りたたみ可能", + "wsPublishedState": "公開時の初期状態", + "wsExpanded": "展開", + "wsCollapsed": "折りたたみ", + "wsCollapsedTitle": "折りたたみ時のタイトル", + "wsTerm": "用語", + "wsListLayout": "リストのレイアウト", + "wsSyncGroup": "同期グループ", + "wsTabKey": "タブのキー", + "wsSwitcherLabel": "切り替えラベル", + "wsNavigationElements": "ナビゲーション要素", + "wsNavigationDepth": "ナビゲーションの深さ", + "wsAddItem": "項目を追加", + "wsStep": "ステップ", + "wsPropertyInvalid": "無効なプロパティ値", + "wsFilter": "フィルター", + "wsIdentifier": "要素 ID", + "wsTldr": "要約 (TLDR)", + "wsFullLayout": "全幅", + "wsMediumLayout": "均等な列", + "wsNarrowLayout": "狭い用語列", + "wsTitle": "タイトル", + "wsSwitcherKey": "切り替えキー", + "nextcloudServerSettings": "サーバーのNotes設定", + "nextcloudNotesPath": "ノートフォルダーのパス", + "nextcloudFileSuffix": "新しいノートの拡張子", + "nextcloudServerSettingsExplanation": "このサーバーのNotes設定は他のクライアントにも影響します。パスを変更してもファイルは移動せず、拡張子を変更しても既存のノートの名前は変わりません。", + "nextcloudSettingsSaved": "サーバー設定を保存しました", + "nextcloudSettingsNormalized": "サーバーが正規化した値で保存しました", + "nextcloudPendingUpload": "添付ファイルのアップロード待ち", + "nextcloudServerCheck": "前回のサーバー確認: {time}", + "nextcloudSettingsLoading": "サーバー設定を読み込み中", + "@nextcloudSettingsLoading": {"description": "Accessible progress label while reading server Notes settings."}, + "nextcloudSettingsSaving": "サーバー設定を保存中", + "@nextcloudSettingsSaving": {"description": "Accessible progress label while writing server Notes settings."}, + "nextcloudPreviousLocalEdit": "以前のローカル編集", + "nextcloudUsePreviousEdit": "以前の編集を使用", + "quickOpen": "クイックオープン", + "notesAll": "すべてのノート", + "notesUncategorized": "未分類", + "notesRecovery": "復元", + "notesRecentlyEdited": "最近編集した順", + "notesOldestEdited": "古い編集順", + "notesTitleAscending": "タイトル昇順", + "notesTitleDescending": "タイトル降順", + "notesMove": "カテゴリに移動", + "notesRemoveFavorite": "お気に入りを解除", + "notesOffline": "オフラインで利用可能にする", + "notesOfflineRemove": "オフライン保持を解除", + "notesIndexing": "ノートを索引作成中…", + "notesSearchHelp": "語句は AND で結合します。\"引用句\"、title:、category: を使えます。", + "notesSourceLocation": "ソース位置", + "notesRecoveryHelp": "保持されたローカルノートとローカル履歴のみ。サーバーのごみ箱や履歴は利用できません。", + "notesImport": "ノートをインポート", + "notesExport": "ノートをエクスポート", + "notesImportDistinct": "別のノートとしてインポート", + "notesAvailableOffline": "オフラインで利用可能", + "notesIncomplete": "不完全", + "notesSelected": "選択したノート", + "notesNoHistory": "保持された版がありません。履歴は無効、期限切れ、または消去済みの場合があります。", + "notesTextAvailable": "テキストはローカルで利用可能", + "notesLocalVersion": "ローカルの作業版", + "notesAttachments": "添付ファイル", + "notesSkipped": "スキップ", + "notesFailed": "失敗", + "notesCompleted": "完了", + "notesImportNew": "新しいインポートを開始", + "notesImportResume": "中断したインポートを再開", + "notesImportAlreadyImported": "この操作ではインポート済み", + "references": "参照", + "referenceIncoming": "被参照", + "referenceOutgoing": "参照先", + "referenceChooseTarget": "リンク先を選択", + "referenceWholeDocument": "文書全体", + "referenceSourceLocation": "参照元の位置", + "referenceTargetLocation": "参照先の位置", + "referenceResolved": "解決済み", + "referenceMissing": "見つかりません", + "referenceAmbiguous": "曖昧", + "referenceUnavailable": "このコンテキストでは利用できません", + "referenceUnsupported": "未対応", + "referenceNoPortableId": "この参照先には確認済みの移植可能なノートURLがありません。" } diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 49ab8b8c..94012928 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -4958,5 +4958,97 @@ "nextcloudPossibleCandidate": "정리된 요청과 일치할 수 있습니다. 동일한 노트인지 확인되지 않았습니다.", "nextcloudSelectCandidate": "검토할 서버 노트 선택", "nextcloudCreateSeparate": "별도 노트 만들기", - "nextcloudCreateSeparateWarning": "첫 요청으로 서버 노트가 이미 만들어졌을 수 있습니다. 보존된 로컬 작업으로 별도 노트를 만듭니다. 중복으로 의심되는 노트는 삭제하지 않습니다." + "nextcloudCreateSeparateWarning": "첫 요청으로 서버 노트가 이미 만들어졌을 수 있습니다. 보존된 로컬 작업으로 별도 노트를 만듭니다. 중복으로 의심되는 노트는 삭제하지 않습니다.", + "wsSemanticFormats": "의미 기반 서식", + "wsUiControl": "UI 컨트롤", + "wsFilePath": "파일 / 경로", + "wsUiPath": "UI 경로", + "wsDefinitionList": "정의 목록", + "wsIncludeContent": "재사용 가능한 콘텐츠 포함", + "wsVariableReference": "변수 참조", + "wsConvertList": "목록을 절차로 변환", + "wsElementProperties": "요소 속성", + "wsTopicProperties": "주제 속성", + "wsCollapsible": "접기 가능", + "wsPublishedState": "게시 시 초기 상태", + "wsExpanded": "펼침", + "wsCollapsed": "접힘", + "wsCollapsedTitle": "접힌 제목", + "wsTerm": "용어", + "wsListLayout": "목록 레이아웃", + "wsSyncGroup": "동기화 그룹", + "wsTabKey": "탭 키", + "wsSwitcherLabel": "전환 레이블", + "wsNavigationElements": "탐색 요소", + "wsNavigationDepth": "탐색 깊이", + "wsAddItem": "항목 추가", + "wsStep": "단계", + "wsPropertyInvalid": "잘못된 속성 값", + "wsFilter": "필터", + "wsIdentifier": "요소 ID", + "wsTldr": "요약 (TLDR)", + "wsFullLayout": "전체 너비", + "wsMediumLayout": "동일한 열", + "wsNarrowLayout": "좁은 용어 열", + "wsTitle": "제목", + "wsSwitcherKey": "전환 키", + "nextcloudServerSettings": "서버 Notes 설정", + "nextcloudNotesPath": "노트 폴더 경로", + "nextcloudFileSuffix": "새 노트의 파일 확장자", + "nextcloudServerSettingsExplanation": "이 서버 Notes 설정은 다른 클라이언트에도 적용됩니다. 경로를 변경해도 파일이 이동하지 않으며 확장자를 변경해도 기존 노트의 이름이 바뀌지 않습니다.", + "nextcloudSettingsSaved": "서버 설정 저장됨", + "nextcloudSettingsNormalized": "서버에서 정규화한 값으로 저장됨", + "nextcloudPendingUpload": "첨부 파일 업로드 대기 중", + "nextcloudServerCheck": "마지막 서버 확인: {time}", + "nextcloudSettingsLoading": "서버 설정을 불러오는 중", + "@nextcloudSettingsLoading": {"description": "Accessible progress label while reading server Notes settings."}, + "nextcloudSettingsSaving": "서버 설정을 저장하는 중", + "@nextcloudSettingsSaving": {"description": "Accessible progress label while writing server Notes settings."}, + "nextcloudPreviousLocalEdit": "이전 로컬 편집", + "nextcloudUsePreviousEdit": "이전 편집 사용", + "quickOpen": "빠른 열기", + "notesAll": "모든 노트", + "notesUncategorized": "미분류", + "notesRecovery": "복구", + "notesRecentlyEdited": "최근 편집순", + "notesOldestEdited": "오래된 편집순", + "notesTitleAscending": "제목 오름차순", + "notesTitleDescending": "제목 내림차순", + "notesMove": "범주로 이동", + "notesRemoveFavorite": "즐겨찾기 해제", + "notesOffline": "오프라인 사용 설정", + "notesOfflineRemove": "오프라인 보존 해제", + "notesIndexing": "노트 색인 생성 중…", + "notesSearchHelp": "검색어는 AND로 결합합니다. \"인용 구문\", title:, category:를 사용하세요.", + "notesSourceLocation": "소스 위치", + "notesRecoveryHelp": "보존된 로컬 노트와 로컬 기록만 표시합니다. 서버 휴지통과 기록은 사용할 수 없습니다.", + "notesImport": "노트 가져오기", + "notesExport": "노트 내보내기", + "notesImportDistinct": "별도 노트로 가져오기", + "notesAvailableOffline": "오프라인 사용 가능", + "notesIncomplete": "불완전", + "notesSelected": "선택한 노트", + "notesNoHistory": "보존된 버전이 없습니다. 기록이 비활성화되었거나 만료 또는 삭제되었을 수 있습니다.", + "notesTextAvailable": "텍스트를 로컬에서 사용 가능", + "notesLocalVersion": "로컬 작업 버전", + "notesAttachments": "첨부 파일", + "notesSkipped": "건너뜀", + "notesFailed": "실패", + "notesCompleted": "완료", + "notesImportNew": "새 가져오기 시작", + "notesImportResume": "중단된 가져오기 재개", + "notesImportAlreadyImported": "이 작업에서 이미 가져옴", + "references": "참조", + "referenceIncoming": "들어오는 참조", + "referenceOutgoing": "나가는 참조", + "referenceChooseTarget": "링크 대상 선택", + "referenceWholeDocument": "전체 문서", + "referenceSourceLocation": "원본 위치", + "referenceTargetLocation": "대상 위치", + "referenceResolved": "해결됨", + "referenceMissing": "없음", + "referenceAmbiguous": "모호함", + "referenceUnavailable": "이 맥락에서 사용할 수 없음", + "referenceUnsupported": "지원되지 않음", + "referenceNoPortableId": "이 대상에는 확인된 이식 가능한 노트 URL이 없습니다." } diff --git a/lib/l10n/app_nb.arb b/lib/l10n/app_nb.arb index 218d41f3..14cea40a 100644 --- a/lib/l10n/app_nb.arb +++ b/lib/l10n/app_nb.arb @@ -3419,5 +3419,97 @@ "nextcloudPossibleCandidate": "Mulig samsvar etter normalisering; identiteten er ubekreftet.", "nextcloudSelectCandidate": "Velg et servernotat som skal gjennomgås", "nextcloudCreateSeparate": "Opprett et separat notat", - "nextcloudCreateSeparateWarning": "Den første forespørselen kan allerede ha opprettet et servernotat. Dette oppretter et separat notat med ditt beholdte lokale arbeid. Ingen antatte duplikater slettes." + "nextcloudCreateSeparateWarning": "Den første forespørselen kan allerede ha opprettet et servernotat. Dette oppretter et separat notat med ditt beholdte lokale arbeid. Ingen antatte duplikater slettes.", + "wsSemanticFormats": "Semantisk formatering", + "wsUiControl": "UI-kontroll", + "wsFilePath": "Fil / Sti", + "wsUiPath": "UI-sti", + "wsDefinitionList": "Definisjonsliste", + "wsIncludeContent": "Inkluder gjenbrukbart innhold", + "wsVariableReference": "Variabelreferanse", + "wsConvertList": "Konverter liste til prosedyre", + "wsElementProperties": "Elementegenskaper", + "wsTopicProperties": "Emneegenskaper", + "wsCollapsible": "Sammenleggbar", + "wsPublishedState": "Publisert starttilstand", + "wsExpanded": "Utvidet", + "wsCollapsed": "Sammenlagt", + "wsCollapsedTitle": "Sammenlagt tittel", + "wsTerm": "Begrep", + "wsListLayout": "Listeoppsett", + "wsSyncGroup": "Synkroniseringsgruppe", + "wsTabKey": "Fanenøkkel", + "wsSwitcherLabel": "Bytteetikett", + "wsNavigationElements": "Navigasjonselementer", + "wsNavigationDepth": "Navigasjonsdybde", + "wsAddItem": "Legg til element", + "wsStep": "Trinn", + "wsPropertyInvalid": "Ugyldig egenskapsverdi", + "wsFilter": "Innholdsfilter", + "wsIdentifier": "Element-ID", + "wsTldr": "Sammendrag (TLDR)", + "wsFullLayout": "Full bredde", + "wsMediumLayout": "Like kolonner", + "wsNarrowLayout": "Smal termkolonne", + "wsTitle": "Tittel", + "wsSwitcherKey": "Byttenøkkel", + "nextcloudServerSettings": "Notes-innstillinger på serveren", + "nextcloudNotesPath": "Sti til notatmappen", + "nextcloudFileSuffix": "Filendelse for nye notater", + "nextcloudServerSettingsExplanation": "Disse Notes-innstillingene påvirker andre klienter. Endring av stien flytter ikke filer; endring av filendelsen gir ikke eksisterende notater nye navn.", + "nextcloudSettingsSaved": "Serverinnstillinger lagret", + "nextcloudSettingsNormalized": "Lagret med verdier normalisert av serveren", + "nextcloudPendingUpload": "Vedlegg venter på opplasting", + "nextcloudServerCheck": "Siste serverkontroll: {time}", + "nextcloudSettingsLoading": "Laster serverinnstillinger", + "@nextcloudSettingsLoading": {"description": "Accessible progress label while reading server Notes settings."}, + "nextcloudSettingsSaving": "Lagrer serverinnstillinger", + "@nextcloudSettingsSaving": {"description": "Accessible progress label while writing server Notes settings."}, + "nextcloudPreviousLocalEdit": "Forrige lokale endring", + "nextcloudUsePreviousEdit": "Bruk forrige endring", + "quickOpen": "Hurtigåpning", + "notesAll": "Alle notater", + "notesUncategorized": "Uten kategori", + "notesRecovery": "Gjenoppretting", + "notesRecentlyEdited": "Nylig redigert", + "notesOldestEdited": "Eldste redigering", + "notesTitleAscending": "Tittel A–Z", + "notesTitleDescending": "Tittel Z–A", + "notesMove": "Flytt til kategori", + "notesRemoveFavorite": "Fjern favoritt", + "notesOffline": "Gjør tilgjengelig frakoblet", + "notesOfflineRemove": "Fjern frakoblet oppbevaring", + "notesIndexing": "Indekserer notater…", + "notesSearchHelp": "Ord bruker OG. Bruk \"siterte fraser\", title: og category:.", + "notesSourceLocation": "Kildeplassering", + "notesRecoveryHelp": "Kun beholdte lokale notater og lokal historikk. Serverens papirkurv og historikk er utilgjengelige.", + "notesImport": "Importer notater", + "notesExport": "Eksporter notater", + "notesImportDistinct": "Importer som eget notat", + "notesAvailableOffline": "Tilgjengelig frakoblet", + "notesIncomplete": "Ufullstendig", + "notesSelected": "Valgte notater", + "notesNoHistory": "Ingen beholdte revisjoner. Historikken kan være deaktivert, utløpt eller slettet.", + "notesTextAvailable": "Tekst tilgjengelig lokalt", + "notesLocalVersion": "Lokal arbeidsversjon", + "notesAttachments": "Vedlegg", + "notesSkipped": "Hoppet over", + "notesFailed": "Mislyktes", + "notesCompleted": "Fullført", + "notesImportNew": "Start en ny import", + "notesImportResume": "Fortsett avbrutt import", + "notesImportAlreadyImported": "Allerede importert i denne operasjonen", + "references": "Referanser", + "referenceIncoming": "Innkommende", + "referenceOutgoing": "Utgående", + "referenceChooseTarget": "Velg lenkemål", + "referenceWholeDocument": "Hele dokumentet", + "referenceSourceLocation": "Kildeplassering", + "referenceTargetLocation": "Målplassering", + "referenceResolved": "Løst", + "referenceMissing": "Mangler", + "referenceAmbiguous": "Tvetydig", + "referenceUnavailable": "Utilgjengelig i denne konteksten", + "referenceUnsupported": "Ikke støttet", + "referenceNoPortableId": "Dette målet har ingen bekreftet portabel notatadresse." } diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index 308d7c3b..a878be00 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -4958,5 +4958,97 @@ "nextcloudPossibleCandidate": "Mogelijke overeenkomst na normalisatie; identiteit onbevestigd.", "nextcloudSelectCandidate": "Selecteer een servernotitie om te bekijken", "nextcloudCreateSeparate": "Een afzonderlijke notitie maken", - "nextcloudCreateSeparateWarning": "Het eerste verzoek heeft mogelijk al een servernotitie gemaakt. Dit maakt een afzonderlijke notitie met uw behouden lokale werk. Vermoedelijke duplicaten worden niet verwijderd." + "nextcloudCreateSeparateWarning": "Het eerste verzoek heeft mogelijk al een servernotitie gemaakt. Dit maakt een afzonderlijke notitie met uw behouden lokale werk. Vermoedelijke duplicaten worden niet verwijderd.", + "wsSemanticFormats": "Semantische opmaak", + "wsUiControl": "UI-besturingselement", + "wsFilePath": "Bestand / Pad", + "wsUiPath": "UI-pad", + "wsDefinitionList": "Definitielijst", + "wsIncludeContent": "Herbruikbare inhoud opnemen", + "wsVariableReference": "Variabelereferentie", + "wsConvertList": "Lijst omzetten naar procedure", + "wsElementProperties": "Elementeigenschappen", + "wsTopicProperties": "Onderwerpeigenschappen", + "wsCollapsible": "Inklapbaar", + "wsPublishedState": "Gepubliceerde beginstatus", + "wsExpanded": "Uitgeklapt", + "wsCollapsed": "Ingeklapt", + "wsCollapsedTitle": "Ingeklapte titel", + "wsTerm": "Begrip", + "wsListLayout": "Lijstindeling", + "wsSyncGroup": "Synchronisatiegroep", + "wsTabKey": "Tabsleutel", + "wsSwitcherLabel": "Schakelaarlabel", + "wsNavigationElements": "Navigatie-elementen", + "wsNavigationDepth": "Navigatiediepte", + "wsAddItem": "Item toevoegen", + "wsStep": "Stap", + "wsPropertyInvalid": "Ongeldige eigenschapswaarde", + "wsFilter": "Inhoudsfilter", + "wsIdentifier": "Element-ID", + "wsTldr": "Samenvatting (TLDR)", + "wsFullLayout": "Volledige breedte", + "wsMediumLayout": "Gelijke kolommen", + "wsNarrowLayout": "Smalle begripskolom", + "wsTitle": "Titel", + "wsSwitcherKey": "Keuzeschakelaar", + "nextcloudServerSettings": "Notes-instellingen op de server", + "nextcloudNotesPath": "Pad van de notitiemap", + "nextcloudFileSuffix": "Bestandsextensie voor nieuwe notities", + "nextcloudServerSettingsExplanation": "Deze Notes-instellingen gelden ook voor andere clients. Een ander pad verplaatst geen bestanden; een andere extensie hernoemt bestaande notities niet.", + "nextcloudSettingsSaved": "Serverinstellingen opgeslagen", + "nextcloudSettingsNormalized": "Opgeslagen met door de server genormaliseerde waarden", + "nextcloudPendingUpload": "Bijlage wacht op upload", + "nextcloudServerCheck": "Laatste servercontrole: {time}", + "nextcloudSettingsLoading": "Serverinstellingen laden", + "@nextcloudSettingsLoading": {"description": "Accessible progress label while reading server Notes settings."}, + "nextcloudSettingsSaving": "Serverinstellingen opslaan", + "@nextcloudSettingsSaving": {"description": "Accessible progress label while writing server Notes settings."}, + "nextcloudPreviousLocalEdit": "Vorige lokale wijziging", + "nextcloudUsePreviousEdit": "Vorige wijziging gebruiken", + "quickOpen": "Snel openen", + "notesAll": "Alle notities", + "notesUncategorized": "Zonder categorie", + "notesRecovery": "Herstel", + "notesRecentlyEdited": "Recent bewerkt", + "notesOldestEdited": "Oudste bewerking", + "notesTitleAscending": "Titel A–Z", + "notesTitleDescending": "Titel Z–A", + "notesMove": "Naar categorie verplaatsen", + "notesRemoveFavorite": "Favoriet verwijderen", + "notesOffline": "Offline beschikbaar maken", + "notesOfflineRemove": "Offlinebewaring verwijderen", + "notesIndexing": "Notities indexeren…", + "notesSearchHelp": "Termen gebruiken EN. Gebruik \"geciteerde zinnen\", title: en category:.", + "notesSourceLocation": "Bronlocatie", + "notesRecoveryHelp": "Alleen bewaarde lokale notities en lokale geschiedenis. Prullenbak en geschiedenis van de server zijn niet beschikbaar.", + "notesImport": "Notities importeren", + "notesExport": "Notities exporteren", + "notesImportDistinct": "Als afzonderlijke notitie importeren", + "notesAvailableOffline": "Offline beschikbaar", + "notesIncomplete": "Onvolledig", + "notesSelected": "Geselecteerde notities", + "notesNoHistory": "Geen bewaarde revisies. Geschiedenis kan uitgeschakeld, verlopen of gewist zijn.", + "notesTextAvailable": "Tekst lokaal beschikbaar", + "notesLocalVersion": "Lokale werkversie", + "notesAttachments": "Bijlagen", + "notesSkipped": "Overgeslagen", + "notesFailed": "Mislukt", + "notesCompleted": "Voltooid", + "notesImportNew": "Nieuwe import starten", + "notesImportResume": "Onderbroken import hervatten", + "notesImportAlreadyImported": "Al geïmporteerd in deze bewerking", + "references": "Verwijzingen", + "referenceIncoming": "Inkomend", + "referenceOutgoing": "Uitgaand", + "referenceChooseTarget": "Linkdoel kiezen", + "referenceWholeDocument": "Heel document", + "referenceSourceLocation": "Bronlocatie", + "referenceTargetLocation": "Doellocatie", + "referenceResolved": "Opgelost", + "referenceMissing": "Ontbreekt", + "referenceAmbiguous": "Dubbelzinnig", + "referenceUnavailable": "Niet beschikbaar in deze context", + "referenceUnsupported": "Niet ondersteund", + "referenceNoPortableId": "Dit doel heeft geen geverifieerde overdraagbare notitie-URL." } diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index f65627db..50a5d37e 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -3445,5 +3445,97 @@ "nextcloudPossibleCandidate": "Możliwa zgodność po normalizacji żądania; tożsamość niepotwierdzona.", "nextcloudSelectCandidate": "Wybierz notatkę z serwera do sprawdzenia", "nextcloudCreateSeparate": "Utwórz osobną notatkę", - "nextcloudCreateSeparateWarning": "Pierwsze żądanie mogło już utworzyć notatkę na serwerze. Ta czynność tworzy osobną notatkę z zachowaną pracą lokalną. Żadne podejrzane duplikaty nie zostaną usunięte." + "nextcloudCreateSeparateWarning": "Pierwsze żądanie mogło już utworzyć notatkę na serwerze. Ta czynność tworzy osobną notatkę z zachowaną pracą lokalną. Żadne podejrzane duplikaty nie zostaną usunięte.", + "wsSemanticFormats": "Formatowanie semantyczne", + "wsUiControl": "Element interfejsu", + "wsFilePath": "Plik / Ścieżka", + "wsUiPath": "Ścieżka interfejsu", + "wsDefinitionList": "Lista definicji", + "wsIncludeContent": "Dołącz treść wielokrotnego użytku", + "wsVariableReference": "Odwołanie do zmiennej", + "wsConvertList": "Przekształć listę w procedurę", + "wsElementProperties": "Właściwości elementu", + "wsTopicProperties": "Właściwości tematu", + "wsCollapsible": "Możliwość zwijania", + "wsPublishedState": "Opublikowany stan początkowy", + "wsExpanded": "Rozwinięty", + "wsCollapsed": "Zwinięty", + "wsCollapsedTitle": "Zwinięty tytuł", + "wsTerm": "Termin", + "wsListLayout": "Układ listy", + "wsSyncGroup": "Grupa synchronizacji", + "wsTabKey": "Klucz karty", + "wsSwitcherLabel": "Etykieta przełącznika", + "wsNavigationElements": "Elementy nawigacji", + "wsNavigationDepth": "Głębokość nawigacji", + "wsAddItem": "Dodaj element", + "wsStep": "Krok", + "wsPropertyInvalid": "Nieprawidłowa wartość właściwości", + "wsFilter": "Filtr", + "wsIdentifier": "Identyfikator elementu", + "wsTldr": "Podsumowanie (TLDR)", + "wsFullLayout": "Pełna szerokość", + "wsMediumLayout": "Równe kolumny", + "wsNarrowLayout": "Wąska kolumna terminu", + "wsTitle": "Tytuł", + "wsSwitcherKey": "Klucz przełącznika", + "nextcloudServerSettings": "Ustawienia Notes na serwerze", + "nextcloudNotesPath": "Ścieżka folderu notatek", + "nextcloudFileSuffix": "Rozszerzenie nowych notatek", + "nextcloudServerSettingsExplanation": "Te ustawienia Notes dotyczą innych klientów. Zmiana ścieżki nie przenosi plików; zmiana rozszerzenia nie zmienia nazw istniejących notatek.", + "nextcloudSettingsSaved": "Zapisano ustawienia serwera", + "nextcloudSettingsNormalized": "Zapisano wartości znormalizowane przez serwer", + "nextcloudPendingUpload": "Załącznik oczekuje na wysłanie", + "nextcloudServerCheck": "Ostatnie sprawdzenie serwera: {time}", + "nextcloudSettingsLoading": "Wczytywanie ustawień serwera", + "@nextcloudSettingsLoading": {"description": "Accessible progress label while reading server Notes settings."}, + "nextcloudSettingsSaving": "Zapisywanie ustawień serwera", + "@nextcloudSettingsSaving": {"description": "Accessible progress label while writing server Notes settings."}, + "nextcloudPreviousLocalEdit": "Poprzednia lokalna zmiana", + "nextcloudUsePreviousEdit": "Użyj poprzedniej zmiany", + "quickOpen": "Szybkie otwieranie", + "notesAll": "Wszystkie notatki", + "notesUncategorized": "Bez kategorii", + "notesRecovery": "Odzyskiwanie", + "notesRecentlyEdited": "Ostatnio edytowane", + "notesOldestEdited": "Najstarsza edycja", + "notesTitleAscending": "Tytuł A–Z", + "notesTitleDescending": "Tytuł Z–A", + "notesMove": "Przenieś do kategorii", + "notesRemoveFavorite": "Usuń z ulubionych", + "notesOffline": "Udostępnij offline", + "notesOfflineRemove": "Usuń przechowywanie offline", + "notesIndexing": "Indeksowanie notatek…", + "notesSearchHelp": "Terminy używają ORAZ. Użyj \"cytowanych fraz\", title: i category:.", + "notesSourceLocation": "Pozycja w źródle", + "notesRecoveryHelp": "Tylko zachowane notatki lokalne i historia lokalna. Kosz i historia serwera są niedostępne.", + "notesImport": "Importuj notatki", + "notesExport": "Eksportuj notatki", + "notesImportDistinct": "Importuj jako osobną notatkę", + "notesAvailableOffline": "Dostępne offline", + "notesIncomplete": "Niekompletne", + "notesSelected": "Wybrane notatki", + "notesNoHistory": "Brak zachowanych wersji. Historia może być wyłączona, wygasła lub wyczyszczona.", + "notesTextAvailable": "Tekst dostępny lokalnie", + "notesLocalVersion": "Lokalna wersja robocza", + "notesAttachments": "Załączniki", + "notesSkipped": "Pominięto", + "notesFailed": "Niepowodzenie", + "notesCompleted": "Ukończono", + "notesImportNew": "Rozpocznij nowy import", + "notesImportResume": "Wznów przerwany import", + "notesImportAlreadyImported": "Już zaimportowano w tej operacji", + "references": "Odwołania", + "referenceIncoming": "Przychodzące", + "referenceOutgoing": "Wychodzące", + "referenceChooseTarget": "Wybierz cel linku", + "referenceWholeDocument": "Cały dokument", + "referenceSourceLocation": "Lokalizacja źródła", + "referenceTargetLocation": "Lokalizacja celu", + "referenceResolved": "Rozwiązane", + "referenceMissing": "Brak", + "referenceAmbiguous": "Niejednoznaczne", + "referenceUnavailable": "Niedostępne w tym kontekście", + "referenceUnsupported": "Nieobsługiwane", + "referenceNoPortableId": "Ten cel nie ma zweryfikowanego przenośnego adresu URL notatki." } diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index 448b4cee..ddc0ff9f 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -3419,5 +3419,97 @@ "nextcloudPossibleCandidate": "Possível correspondência após normalização; identidade não confirmada.", "nextcloudSelectCandidate": "Selecionar uma nota do servidor para rever", "nextcloudCreateSeparate": "Criar uma nota separada", - "nextcloudCreateSeparateWarning": "O primeiro pedido pode já ter criado uma nota no servidor. Isto cria uma nota separada com o trabalho local preservado. Nenhum possível duplicado será eliminado." + "nextcloudCreateSeparateWarning": "O primeiro pedido pode já ter criado uma nota no servidor. Isto cria uma nota separada com o trabalho local preservado. Nenhum possível duplicado será eliminado.", + "wsSemanticFormats": "Formatação semântica", + "wsUiControl": "Controlo da interface", + "wsFilePath": "Ficheiro / Caminho", + "wsUiPath": "Caminho da interface", + "wsDefinitionList": "Lista de definições", + "wsIncludeContent": "Incluir conteúdo reutilizável", + "wsVariableReference": "Referência de variável", + "wsConvertList": "Converter lista em procedimento", + "wsElementProperties": "Propriedades do elemento", + "wsTopicProperties": "Propriedades do tópico", + "wsCollapsible": "Recolhível", + "wsPublishedState": "Estado inicial publicado", + "wsExpanded": "Expandido", + "wsCollapsed": "Recolhido", + "wsCollapsedTitle": "Título recolhido", + "wsTerm": "Termo", + "wsListLayout": "Disposição da lista", + "wsSyncGroup": "Grupo de sincronização", + "wsTabKey": "Chave do separador", + "wsSwitcherLabel": "Etiqueta do seletor", + "wsNavigationElements": "Elementos de navegação", + "wsNavigationDepth": "Profundidade de navegação", + "wsAddItem": "Adicionar item", + "wsStep": "Passo", + "wsPropertyInvalid": "Valor de propriedade inválido", + "wsFilter": "Filtro", + "wsIdentifier": "ID do elemento", + "wsTldr": "Resumo (TLDR)", + "wsFullLayout": "Largura total", + "wsMediumLayout": "Colunas iguais", + "wsNarrowLayout": "Coluna de termo estreita", + "wsTitle": "Título", + "wsSwitcherKey": "Chave do seletor", + "nextcloudServerSettings": "Definições de Notes do servidor", + "nextcloudNotesPath": "Caminho da pasta de notas", + "nextcloudFileSuffix": "Extensão de novas notas", + "nextcloudServerSettingsExplanation": "Estas definições de Notes afetam outros clientes. Alterar o caminho não move ficheiros; alterar a extensão não renomeia notas existentes.", + "nextcloudSettingsSaved": "Definições do servidor guardadas", + "nextcloudSettingsNormalized": "Guardado com valores normalizados pelo servidor", + "nextcloudPendingUpload": "Envio de anexo pendente", + "nextcloudServerCheck": "Última verificação do servidor: {time}", + "nextcloudSettingsLoading": "A carregar as definições do servidor", + "@nextcloudSettingsLoading": {"description": "Accessible progress label while reading server Notes settings."}, + "nextcloudSettingsSaving": "A guardar as definições do servidor", + "@nextcloudSettingsSaving": {"description": "Accessible progress label while writing server Notes settings."}, + "nextcloudPreviousLocalEdit": "Edição local anterior", + "nextcloudUsePreviousEdit": "Usar edição anterior", + "quickOpen": "Abertura rápida", + "notesAll": "Todas as notas", + "notesUncategorized": "Sem categoria", + "notesRecovery": "Recuperação", + "notesRecentlyEdited": "Editadas recentemente", + "notesOldestEdited": "Edições mais antigas", + "notesTitleAscending": "Título A–Z", + "notesTitleDescending": "Título Z–A", + "notesMove": "Mover para categoria", + "notesRemoveFavorite": "Remover favorito", + "notesOffline": "Disponibilizar offline", + "notesOfflineRemove": "Remover retenção offline", + "notesIndexing": "A indexar notas…", + "notesSearchHelp": "Os termos usam E. Use \"frases entre aspas\", title: e category:.", + "notesSourceLocation": "Localização no código fonte", + "notesRecoveryHelp": "Apenas notas locais retidas e histórico local. O lixo e o histórico do servidor não estão disponíveis.", + "notesImport": "Importar notas", + "notesExport": "Exportar notas", + "notesImportDistinct": "Importar como nota distinta", + "notesAvailableOffline": "Disponível offline", + "notesIncomplete": "Incompleto", + "notesSelected": "Notas selecionadas", + "notesNoHistory": "Não há revisões retidas. O histórico pode estar desativado, expirado ou apagado.", + "notesTextAvailable": "Texto disponível localmente", + "notesLocalVersion": "Versão de trabalho local", + "notesAttachments": "Anexos", + "notesSkipped": "Ignorado", + "notesFailed": "Falhou", + "notesCompleted": "Concluído", + "notesImportNew": "Iniciar nova importação", + "notesImportResume": "Retomar importação interrompida", + "notesImportAlreadyImported": "Já importado nesta operação", + "references": "Referências", + "referenceIncoming": "Recebidas", + "referenceOutgoing": "Enviadas", + "referenceChooseTarget": "Escolher destino da ligação", + "referenceWholeDocument": "Documento completo", + "referenceSourceLocation": "Localização de origem", + "referenceTargetLocation": "Localização de destino", + "referenceResolved": "Resolvida", + "referenceMissing": "Em falta", + "referenceAmbiguous": "Ambígua", + "referenceUnavailable": "Indisponível neste contexto", + "referenceUnsupported": "Não suportada", + "referenceNoPortableId": "Este destino não tem um URL portátil de nota verificado." } diff --git a/lib/l10n/app_pt_BR.arb b/lib/l10n/app_pt_BR.arb index 5f6cdadf..274a328e 100644 --- a/lib/l10n/app_pt_BR.arb +++ b/lib/l10n/app_pt_BR.arb @@ -3419,5 +3419,97 @@ "nextcloudPossibleCandidate": "Possível correspondência após normalização; identidade não confirmada.", "nextcloudSelectCandidate": "Selecionar uma nota do servidor para revisar", "nextcloudCreateSeparate": "Criar uma nota separada", - "nextcloudCreateSeparateWarning": "A primeira solicitação pode já ter criado uma nota no servidor. Isso cria uma nota separada com o trabalho local preservado. Nenhuma possível duplicata será excluída." + "nextcloudCreateSeparateWarning": "A primeira solicitação pode já ter criado uma nota no servidor. Isso cria uma nota separada com o trabalho local preservado. Nenhuma possível duplicata será excluída.", + "wsSemanticFormats": "Formatação semântica", + "wsUiControl": "Controle da interface", + "wsFilePath": "Arquivo / Caminho", + "wsUiPath": "Caminho da interface", + "wsDefinitionList": "Lista de definições", + "wsIncludeContent": "Incluir conteúdo reutilizável", + "wsVariableReference": "Referência de variável", + "wsConvertList": "Converter lista em procedimento", + "wsElementProperties": "Propriedades do elemento", + "wsTopicProperties": "Propriedades do tópico", + "wsCollapsible": "Recolhível", + "wsPublishedState": "Estado inicial publicado", + "wsExpanded": "Expandido", + "wsCollapsed": "Recolhido", + "wsCollapsedTitle": "Título recolhido", + "wsTerm": "Termo", + "wsListLayout": "Disposição da lista", + "wsSyncGroup": "Grupo de sincronização", + "wsTabKey": "Chave do aba", + "wsSwitcherLabel": "Etiqueta do seletor", + "wsNavigationElements": "Elementos de navegação", + "wsNavigationDepth": "Profundidade de navegação", + "wsAddItem": "Adicionar item", + "wsStep": "Passo", + "wsPropertyInvalid": "Valor de propriedade inválido", + "wsFilter": "Filtro", + "wsIdentifier": "ID do elemento", + "wsTldr": "Resumo (TLDR)", + "wsFullLayout": "Largura total", + "wsMediumLayout": "Colunas iguais", + "wsNarrowLayout": "Coluna de termo estreita", + "wsTitle": "Título", + "wsSwitcherKey": "Chave do seletor", + "nextcloudServerSettings": "Configurações de Notes do servidor", + "nextcloudNotesPath": "Caminho da pasta de notas", + "nextcloudFileSuffix": "Extensão de novas notas", + "nextcloudServerSettingsExplanation": "Estas configurações de Notes afetam outros clientes. Alterar o caminho não move arquivos; alterar a extensão não renomeia notas existentes.", + "nextcloudSettingsSaved": "Configurações do servidor salvas", + "nextcloudSettingsNormalized": "Salvo com valores normalizados pelo servidor", + "nextcloudPendingUpload": "Envio de anexo pendente", + "nextcloudServerCheck": "Última verificação do servidor: {time}", + "nextcloudSettingsLoading": "Carregando configurações do servidor", + "@nextcloudSettingsLoading": {"description": "Accessible progress label while reading server Notes settings."}, + "nextcloudSettingsSaving": "Salvando configurações do servidor", + "@nextcloudSettingsSaving": {"description": "Accessible progress label while writing server Notes settings."}, + "nextcloudPreviousLocalEdit": "Edição local anterior", + "nextcloudUsePreviousEdit": "Usar edição anterior", + "quickOpen": "Abertura rápida", + "notesAll": "Todas as notas", + "notesUncategorized": "Sem categoria", + "notesRecovery": "Recuperação", + "notesRecentlyEdited": "Editadas recentemente", + "notesOldestEdited": "Edições mais antigas", + "notesTitleAscending": "Título A–Z", + "notesTitleDescending": "Título Z–A", + "notesMove": "Mover para categoria", + "notesRemoveFavorite": "Remover favorito", + "notesOffline": "Disponibilizar offline", + "notesOfflineRemove": "Remover retenção offline", + "notesIndexing": "Indexando notas…", + "notesSearchHelp": "Os termos usam E. Use \"frases entre aspas\", title: e category:.", + "notesSourceLocation": "Localização no código fonte", + "notesRecoveryHelp": "Apenas notas locais retidas e histórico local. O lixo e o histórico do servidor não estão disponíveis.", + "notesImport": "Importar notas", + "notesExport": "Exportar notas", + "notesImportDistinct": "Importar como nota distinta", + "notesAvailableOffline": "Disponível offline", + "notesIncomplete": "Incompleto", + "notesSelected": "Notas selecionadas", + "notesNoHistory": "Não há revisões retidas. O histórico pode estar desativado, expirado ou apagado.", + "notesTextAvailable": "Texto disponível localmente", + "notesLocalVersion": "Versão de trabalho local", + "notesAttachments": "Anexos", + "notesSkipped": "Ignorado", + "notesFailed": "Falhou", + "notesCompleted": "Concluído", + "notesImportNew": "Iniciar nova importação", + "notesImportResume": "Retomar importação interrompida", + "notesImportAlreadyImported": "Já importado nesta operação", + "references": "Referências", + "referenceIncoming": "Recebidas", + "referenceOutgoing": "Enviadas", + "referenceChooseTarget": "Escolher destino do link", + "referenceWholeDocument": "Documento completo", + "referenceSourceLocation": "Local de origem", + "referenceTargetLocation": "Local de destino", + "referenceResolved": "Resolvida", + "referenceMissing": "Ausente", + "referenceAmbiguous": "Ambígua", + "referenceUnavailable": "Indisponível neste contexto", + "referenceUnsupported": "Não compatível", + "referenceNoPortableId": "Este destino não tem uma URL portátil de nota verificada." } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index e2553671..858631bb 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -3445,5 +3445,97 @@ "nextcloudPossibleCandidate": "Возможное совпадение после нормализации; идентичность не подтверждена.", "nextcloudSelectCandidate": "Выберите заметку на сервере для проверки", "nextcloudCreateSeparate": "Создать отдельную заметку", - "nextcloudCreateSeparateWarning": "Первый запрос мог уже создать заметку на сервере. Будет создана отдельная заметка с сохранённой локальной работой. Предполагаемые дубликаты не будут удалены." + "nextcloudCreateSeparateWarning": "Первый запрос мог уже создать заметку на сервере. Будет создана отдельная заметка с сохранённой локальной работой. Предполагаемые дубликаты не будут удалены.", + "wsSemanticFormats": "Семантическое форматирование", + "wsUiControl": "Элемент интерфейса", + "wsFilePath": "Файл / Путь", + "wsUiPath": "Путь в интерфейсе", + "wsDefinitionList": "Список определений", + "wsIncludeContent": "Включить повторно используемый контент", + "wsVariableReference": "Ссылка на переменную", + "wsConvertList": "Преобразовать список в процедуру", + "wsElementProperties": "Свойства элемента", + "wsTopicProperties": "Свойства темы", + "wsCollapsible": "Сворачиваемый", + "wsPublishedState": "Начальное состояние публикации", + "wsExpanded": "Развёрнуто", + "wsCollapsed": "Свёрнуто", + "wsCollapsedTitle": "Заголовок в свёрнутом виде", + "wsTerm": "Термин", + "wsListLayout": "Макет списка", + "wsSyncGroup": "Группа синхронизации", + "wsTabKey": "Ключ вкладки", + "wsSwitcherLabel": "Подпись переключателя", + "wsNavigationElements": "Элементы навигации", + "wsNavigationDepth": "Глубина навигации", + "wsAddItem": "Добавить элемент", + "wsStep": "Шаг", + "wsPropertyInvalid": "Недопустимое значение свойства", + "wsFilter": "Фильтр", + "wsIdentifier": "Идентификатор элемента", + "wsTldr": "Краткое содержание (TLDR)", + "wsFullLayout": "Полная ширина", + "wsMediumLayout": "Равные столбцы", + "wsNarrowLayout": "Узкий столбец термина", + "wsTitle": "Заголовок", + "wsSwitcherKey": "Ключ переключателя", + "nextcloudServerSettings": "Настройки Notes на сервере", + "nextcloudNotesPath": "Путь к папке заметок", + "nextcloudFileSuffix": "Расширение новых заметок", + "nextcloudServerSettingsExplanation": "Эти настройки Notes влияют на другие клиенты. Изменение пути не перемещает файлы; изменение расширения не переименовывает существующие заметки.", + "nextcloudSettingsSaved": "Настройки сервера сохранены", + "nextcloudSettingsNormalized": "Сохранено со значениями, нормализованными сервером", + "nextcloudPendingUpload": "Вложение ожидает загрузки", + "nextcloudServerCheck": "Последняя проверка сервера: {time}", + "nextcloudSettingsLoading": "Загрузка настроек сервера", + "@nextcloudSettingsLoading": {"description": "Accessible progress label while reading server Notes settings."}, + "nextcloudSettingsSaving": "Сохранение настроек сервера", + "@nextcloudSettingsSaving": {"description": "Accessible progress label while writing server Notes settings."}, + "nextcloudPreviousLocalEdit": "Предыдущая локальная правка", + "nextcloudUsePreviousEdit": "Использовать предыдущую правку", + "quickOpen": "Быстрое открытие", + "notesAll": "Все заметки", + "notesUncategorized": "Без категории", + "notesRecovery": "Восстановление", + "notesRecentlyEdited": "Недавно изменённые", + "notesOldestEdited": "Давно изменённые", + "notesTitleAscending": "Название А–Я", + "notesTitleDescending": "Название Я–А", + "notesMove": "Переместить в категорию", + "notesRemoveFavorite": "Убрать из избранного", + "notesOffline": "Сохранить для работы без сети", + "notesOfflineRemove": "Убрать сохранение без сети", + "notesIndexing": "Индексация заметок…", + "notesSearchHelp": "Термины объединяются И. Используйте \"фразы в кавычках\", title: и category:.", + "notesSourceLocation": "Место в исходнике", + "notesRecoveryHelp": "Только сохранённые локальные заметки и локальная история. Корзина и история сервера недоступны.", + "notesImport": "Импорт заметок", + "notesExport": "Экспорт заметок", + "notesImportDistinct": "Импортировать как отдельную заметку", + "notesAvailableOffline": "Доступно без сети", + "notesIncomplete": "Неполно", + "notesSelected": "Выбранные заметки", + "notesNoHistory": "Нет сохранённых версий. История может быть отключена, устарела или очищена.", + "notesTextAvailable": "Текст доступен локально", + "notesLocalVersion": "Локальная рабочая версия", + "notesAttachments": "Вложения", + "notesSkipped": "Пропущено", + "notesFailed": "Ошибка", + "notesCompleted": "Завершено", + "notesImportNew": "Начать новый импорт", + "notesImportResume": "Продолжить прерванный импорт", + "notesImportAlreadyImported": "Уже импортировано в этой операции", + "references": "Ссылки", + "referenceIncoming": "Входящие", + "referenceOutgoing": "Исходящие", + "referenceChooseTarget": "Выберите цель ссылки", + "referenceWholeDocument": "Весь документ", + "referenceSourceLocation": "Положение источника", + "referenceTargetLocation": "Положение цели", + "referenceResolved": "Найдена", + "referenceMissing": "Отсутствует", + "referenceAmbiguous": "Неоднозначная", + "referenceUnavailable": "Недоступна в этом контексте", + "referenceUnsupported": "Не поддерживается", + "referenceNoPortableId": "У этой цели нет проверенного переносимого URL заметки." } diff --git a/lib/l10n/app_tr.arb b/lib/l10n/app_tr.arb index 8c2ca4d1..eee24478 100644 --- a/lib/l10n/app_tr.arb +++ b/lib/l10n/app_tr.arb @@ -4958,5 +4958,97 @@ "nextcloudPossibleCandidate": "Düzenlenmiş istekle olası eşleşme; kimlik doğrulanmadı.", "nextcloudSelectCandidate": "İncelemek için bir sunucu notu seçin", "nextcloudCreateSeparate": "Ayrı bir not oluştur", - "nextcloudCreateSeparateWarning": "İlk istek sunucuda zaten bir not oluşturmuş olabilir. Bu işlem korunan yerel çalışmanızla ayrı bir not oluşturur. Olası kopyalar silinmez." + "nextcloudCreateSeparateWarning": "İlk istek sunucuda zaten bir not oluşturmuş olabilir. Bu işlem korunan yerel çalışmanızla ayrı bir not oluşturur. Olası kopyalar silinmez.", + "wsSemanticFormats": "Anlamsal biçimlendirme", + "wsUiControl": "Arayüz denetimi", + "wsFilePath": "Dosya / Yol", + "wsUiPath": "Arayüz yolu", + "wsDefinitionList": "Tanım listesi", + "wsIncludeContent": "Yeniden kullanılabilir içerik ekle", + "wsVariableReference": "Değişken başvurusu", + "wsConvertList": "Listeyi prosedüre dönüştür", + "wsElementProperties": "Öğe özellikleri", + "wsTopicProperties": "Konu özellikleri", + "wsCollapsible": "Daraltılabilir", + "wsPublishedState": "Yayımlanan başlangıç durumu", + "wsExpanded": "Genişletilmiş", + "wsCollapsed": "Daraltılmış", + "wsCollapsedTitle": "Daraltılmış başlık", + "wsTerm": "Terim", + "wsListLayout": "Liste düzeni", + "wsSyncGroup": "Eşitleme grubu", + "wsTabKey": "Sekme anahtarı", + "wsSwitcherLabel": "Değiştirici etiketi", + "wsNavigationElements": "Gezinme öğeleri", + "wsNavigationDepth": "Gezinme derinliği", + "wsAddItem": "Öğe ekle", + "wsStep": "Adım", + "wsPropertyInvalid": "Geçersiz özellik değeri", + "wsFilter": "Filtre", + "wsIdentifier": "Öğe kimliği", + "wsTldr": "Özet (TLDR)", + "wsFullLayout": "Tam genişlik", + "wsMediumLayout": "Eşit sütunlar", + "wsNarrowLayout": "Dar terim sütunu", + "wsTitle": "Başlık", + "wsSwitcherKey": "Değiştirici anahtarı", + "nextcloudServerSettings": "Sunucu Notes ayarları", + "nextcloudNotesPath": "Not klasörünün yolu", + "nextcloudFileSuffix": "Yeni notların dosya uzantısı", + "nextcloudServerSettingsExplanation": "Bu sunucu Notes ayarları diğer istemcileri de etkiler. Yolun değiştirilmesi dosyaları taşımaz; uzantının değiştirilmesi mevcut notları yeniden adlandırmaz.", + "nextcloudSettingsSaved": "Sunucu ayarları kaydedildi", + "nextcloudSettingsNormalized": "Sunucunun normalleştirdiği değerlerle kaydedildi", + "nextcloudPendingUpload": "Ek yüklemesi bekliyor", + "nextcloudServerCheck": "Son sunucu kontrolü: {time}", + "nextcloudSettingsLoading": "Sunucu ayarları yükleniyor", + "@nextcloudSettingsLoading": {"description": "Accessible progress label while reading server Notes settings."}, + "nextcloudSettingsSaving": "Sunucu ayarları kaydediliyor", + "@nextcloudSettingsSaving": {"description": "Accessible progress label while writing server Notes settings."}, + "nextcloudPreviousLocalEdit": "Önceki yerel düzenleme", + "nextcloudUsePreviousEdit": "Önceki düzenlemeyi kullan", + "quickOpen": "Hızlı aç", + "notesAll": "Tüm notlar", + "notesUncategorized": "Kategorisiz", + "notesRecovery": "Kurtarma", + "notesRecentlyEdited": "Son düzenlenen", + "notesOldestEdited": "En eski düzenleme", + "notesTitleAscending": "Başlık A–Z", + "notesTitleDescending": "Başlık Z–A", + "notesMove": "Kategoriye taşı", + "notesRemoveFavorite": "Favoriyi kaldır", + "notesOffline": "Çevrimdışı kullanılabilir yap", + "notesOfflineRemove": "Çevrimdışı saklamayı kaldır", + "notesIndexing": "Notlar dizine ekleniyor…", + "notesSearchHelp": "Terimler VE ile birleştirilir. \"Alıntı ifadeler\", title: ve category: kullanın.", + "notesSourceLocation": "Kaynak konumu", + "notesRecoveryHelp": "Yalnızca saklanan yerel notlar ve yerel geçmiş. Sunucu çöp kutusu ve geçmişi kullanılamaz.", + "notesImport": "Notları içe aktar", + "notesExport": "Notları dışa aktar", + "notesImportDistinct": "Ayrı bir not olarak içe aktar", + "notesAvailableOffline": "Çevrimdışı kullanılabilir", + "notesIncomplete": "Eksik", + "notesSelected": "Seçilen notlar", + "notesNoHistory": "Saklanan sürüm yok. Geçmiş kapalı, süresi dolmuş veya temizlenmiş olabilir.", + "notesTextAvailable": "Metin yerel olarak kullanılabilir", + "notesLocalVersion": "Yerel çalışma sürümü", + "notesAttachments": "Ekler", + "notesSkipped": "Atlandı", + "notesFailed": "Başarısız", + "notesCompleted": "Tamamlandı", + "notesImportNew": "Yeni içe aktarma başlat", + "notesImportResume": "Kesilen içe aktarmayı sürdür", + "notesImportAlreadyImported": "Bu işlemde zaten içe aktarıldı", + "references": "Başvurular", + "referenceIncoming": "Gelen", + "referenceOutgoing": "Giden", + "referenceChooseTarget": "Bağlantı hedefini seç", + "referenceWholeDocument": "Tüm belge", + "referenceSourceLocation": "Kaynak konumu", + "referenceTargetLocation": "Hedef konumu", + "referenceResolved": "Çözüldü", + "referenceMissing": "Eksik", + "referenceAmbiguous": "Belirsiz", + "referenceUnavailable": "Bu bağlamda kullanılamıyor", + "referenceUnsupported": "Desteklenmiyor", + "referenceNoPortableId": "Bu hedefin doğrulanmış taşınabilir not URL’si yok." } diff --git a/lib/l10n/app_uk.arb b/lib/l10n/app_uk.arb index a3f6a69d..0977d30f 100644 --- a/lib/l10n/app_uk.arb +++ b/lib/l10n/app_uk.arb @@ -3445,5 +3445,97 @@ "nextcloudPossibleCandidate": "Можливий збіг після нормалізації; тотожність не підтверджено.", "nextcloudSelectCandidate": "Виберіть нотатку на сервері для перегляду", "nextcloudCreateSeparate": "Створити окрему нотатку", - "nextcloudCreateSeparateWarning": "Перший запит міг уже створити нотатку на сервері. Буде створено окрему нотатку зі збереженою локальною роботою. Ймовірні дублікати не буде видалено." + "nextcloudCreateSeparateWarning": "Перший запит міг уже створити нотатку на сервері. Буде створено окрему нотатку зі збереженою локальною роботою. Ймовірні дублікати не буде видалено.", + "wsSemanticFormats": "Семантичне форматування", + "wsUiControl": "Елемент інтерфейсу", + "wsFilePath": "Файл / Шлях", + "wsUiPath": "Шлях в інтерфейсі", + "wsDefinitionList": "Список визначень", + "wsIncludeContent": "Додати повторно використовуваний вміст", + "wsVariableReference": "Посилання на змінну", + "wsConvertList": "Перетворити список на процедуру", + "wsElementProperties": "Властивості елемента", + "wsTopicProperties": "Властивості теми", + "wsCollapsible": "Згортання", + "wsPublishedState": "Початковий стан публікації", + "wsExpanded": "Розгорнуто", + "wsCollapsed": "Згорнуто", + "wsCollapsedTitle": "Заголовок у згорнутому стані", + "wsTerm": "Термін", + "wsListLayout": "Макет списку", + "wsSyncGroup": "Група синхронізації", + "wsTabKey": "Ключ вкладки", + "wsSwitcherLabel": "Підпис перемикача", + "wsNavigationElements": "Елементи навігації", + "wsNavigationDepth": "Глибина навігації", + "wsAddItem": "Додати елемент", + "wsStep": "Крок", + "wsPropertyInvalid": "Неприпустиме значення властивості", + "wsFilter": "Фільтр", + "wsIdentifier": "Ідентифікатор елемента", + "wsTldr": "Короткий виклад (TLDR)", + "wsFullLayout": "Повна ширина", + "wsMediumLayout": "Рівні стовпці", + "wsNarrowLayout": "Вузький стовпець терміна", + "wsTitle": "Заголовок", + "wsSwitcherKey": "Ключ перемикача", + "nextcloudServerSettings": "Налаштування Notes на сервері", + "nextcloudNotesPath": "Шлях до теки нотаток", + "nextcloudFileSuffix": "Розширення нових нотаток", + "nextcloudServerSettingsExplanation": "Ці налаштування Notes впливають на інші клієнти. Зміна шляху не переміщує файли; зміна розширення не перейменовує наявні нотатки.", + "nextcloudSettingsSaved": "Налаштування сервера збережено", + "nextcloudSettingsNormalized": "Збережено зі значеннями, нормалізованими сервером", + "nextcloudPendingUpload": "Вкладення очікує завантаження", + "nextcloudServerCheck": "Остання перевірка сервера: {time}", + "nextcloudSettingsLoading": "Завантаження налаштувань сервера", + "@nextcloudSettingsLoading": {"description": "Accessible progress label while reading server Notes settings."}, + "nextcloudSettingsSaving": "Збереження налаштувань сервера", + "@nextcloudSettingsSaving": {"description": "Accessible progress label while writing server Notes settings."}, + "nextcloudPreviousLocalEdit": "Попередня локальна зміна", + "nextcloudUsePreviousEdit": "Використати попередню зміну", + "quickOpen": "Швидке відкриття", + "notesAll": "Усі нотатки", + "notesUncategorized": "Без категорії", + "notesRecovery": "Відновлення", + "notesRecentlyEdited": "Нещодавно змінені", + "notesOldestEdited": "Давно змінені", + "notesTitleAscending": "Назва А–Я", + "notesTitleDescending": "Назва Я–А", + "notesMove": "Перемістити до категорії", + "notesRemoveFavorite": "Вилучити з обраного", + "notesOffline": "Зберегти для роботи без мережі", + "notesOfflineRemove": "Вилучити збереження без мережі", + "notesIndexing": "Індексація нотаток…", + "notesSearchHelp": "Терміни поєднуються І. Використовуйте \"фрази в лапках\", title: та category:.", + "notesSourceLocation": "Місце в джерелі", + "notesRecoveryHelp": "Лише збережені локальні нотатки та локальна історія. Кошик та історія сервера недоступні.", + "notesImport": "Імпорт нотаток", + "notesExport": "Експорт нотаток", + "notesImportDistinct": "Імпортувати як окрему нотатку", + "notesAvailableOffline": "Доступно без мережі", + "notesIncomplete": "Неповно", + "notesSelected": "Вибрані нотатки", + "notesNoHistory": "Немає збережених версій. Історію може бути вимкнено, прострочено або очищено.", + "notesTextAvailable": "Текст доступний локально", + "notesLocalVersion": "Локальна робоча версія", + "notesAttachments": "Вкладення", + "notesSkipped": "Пропущено", + "notesFailed": "Помилка", + "notesCompleted": "Завершено", + "notesImportNew": "Почати новий імпорт", + "notesImportResume": "Продовжити перерваний імпорт", + "notesImportAlreadyImported": "Уже імпортовано в цій операції", + "references": "Посилання", + "referenceIncoming": "Вхідні", + "referenceOutgoing": "Вихідні", + "referenceChooseTarget": "Виберіть ціль посилання", + "referenceWholeDocument": "Увесь документ", + "referenceSourceLocation": "Позиція джерела", + "referenceTargetLocation": "Позиція цілі", + "referenceResolved": "Знайдено", + "referenceMissing": "Відсутнє", + "referenceAmbiguous": "Неоднозначне", + "referenceUnavailable": "Недоступне в цьому контексті", + "referenceUnsupported": "Не підтримується", + "referenceNoPortableId": "Ця ціль не має перевіреної переносної URL-адреси нотатки." } diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index 27e538ac..bc872de1 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -4958,5 +4958,97 @@ "nextcloudPossibleCandidate": "Có thể khớp sau khi chuẩn hóa yêu cầu; chưa xác nhận danh tính.", "nextcloudSelectCandidate": "Chọn ghi chú trên máy chủ để xem xét", "nextcloudCreateSeparate": "Tạo ghi chú riêng", - "nextcloudCreateSeparateWarning": "Yêu cầu đầu tiên có thể đã tạo ghi chú trên máy chủ. Thao tác này tạo ghi chú riêng với công việc cục bộ được giữ lại. Không xóa ghi chú nào bị nghi trùng lặp." + "nextcloudCreateSeparateWarning": "Yêu cầu đầu tiên có thể đã tạo ghi chú trên máy chủ. Thao tác này tạo ghi chú riêng với công việc cục bộ được giữ lại. Không xóa ghi chú nào bị nghi trùng lặp.", + "wsSemanticFormats": "Định dạng ngữ nghĩa", + "wsUiControl": "Điều khiển giao diện", + "wsFilePath": "Tệp / Đường dẫn", + "wsUiPath": "Đường dẫn giao diện", + "wsDefinitionList": "Danh sách định nghĩa", + "wsIncludeContent": "Chèn nội dung tái sử dụng", + "wsVariableReference": "Tham chiếu biến", + "wsConvertList": "Chuyển danh sách thành quy trình", + "wsElementProperties": "Thuộc tính phần tử", + "wsTopicProperties": "Thuộc tính chủ đề", + "wsCollapsible": "Có thể thu gọn", + "wsPublishedState": "Trạng thái ban đầu khi xuất bản", + "wsExpanded": "Mở rộng", + "wsCollapsed": "Thu gọn", + "wsCollapsedTitle": "Tiêu đề thu gọn", + "wsTerm": "Thuật ngữ", + "wsListLayout": "Bố cục danh sách", + "wsSyncGroup": "Nhóm đồng bộ", + "wsTabKey": "Khóa thẻ", + "wsSwitcherLabel": "Nhãn bộ chuyển", + "wsNavigationElements": "Phần tử điều hướng", + "wsNavigationDepth": "Độ sâu điều hướng", + "wsAddItem": "Thêm mục", + "wsStep": "Bước", + "wsPropertyInvalid": "Giá trị thuộc tính không hợp lệ", + "wsFilter": "Bộ lọc", + "wsIdentifier": "ID phần tử", + "wsTldr": "Tóm tắt (TLDR)", + "wsFullLayout": "Toàn chiều rộng", + "wsMediumLayout": "Các cột bằng nhau", + "wsNarrowLayout": "Cột thuật ngữ hẹp", + "wsTitle": "Tiêu đề", + "wsSwitcherKey": "Khóa chuyển đổi", + "nextcloudServerSettings": "Cài đặt Notes trên máy chủ", + "nextcloudNotesPath": "Đường dẫn thư mục ghi chú", + "nextcloudFileSuffix": "Phần mở rộng của ghi chú mới", + "nextcloudServerSettingsExplanation": "Các cài đặt Notes này ảnh hưởng đến các ứng dụng khách khác. Đổi đường dẫn không di chuyển tệp; đổi phần mở rộng không đổi tên ghi chú hiện có.", + "nextcloudSettingsSaved": "Đã lưu cài đặt máy chủ", + "nextcloudSettingsNormalized": "Đã lưu các giá trị được máy chủ chuẩn hóa", + "nextcloudPendingUpload": "Đang chờ tải tệp đính kèm lên", + "nextcloudServerCheck": "Lần kiểm tra máy chủ gần nhất: {time}", + "nextcloudSettingsLoading": "Đang tải cài đặt máy chủ", + "@nextcloudSettingsLoading": {"description": "Accessible progress label while reading server Notes settings."}, + "nextcloudSettingsSaving": "Đang lưu cài đặt máy chủ", + "@nextcloudSettingsSaving": {"description": "Accessible progress label while writing server Notes settings."}, + "nextcloudPreviousLocalEdit": "Chỉnh sửa cục bộ trước", + "nextcloudUsePreviousEdit": "Dùng chỉnh sửa trước", + "quickOpen": "Mở nhanh", + "notesAll": "Tất cả ghi chú", + "notesUncategorized": "Chưa phân loại", + "notesRecovery": "Khôi phục", + "notesRecentlyEdited": "Sửa gần đây", + "notesOldestEdited": "Sửa lâu nhất", + "notesTitleAscending": "Tiêu đề A–Z", + "notesTitleDescending": "Tiêu đề Z–A", + "notesMove": "Chuyển tới danh mục", + "notesRemoveFavorite": "Bỏ yêu thích", + "notesOffline": "Cho phép dùng ngoại tuyến", + "notesOfflineRemove": "Bỏ yêu cầu lưu ngoại tuyến", + "notesIndexing": "Đang lập chỉ mục ghi chú…", + "notesSearchHelp": "Các từ kết hợp bằng VÀ. Dùng \"cụm từ trích dẫn\", title: và category:.", + "notesSourceLocation": "Vị trí mã nguồn", + "notesRecoveryHelp": "Chỉ gồm ghi chú cục bộ được giữ và lịch sử cục bộ. Không có thùng rác hoặc lịch sử máy chủ.", + "notesImport": "Nhập ghi chú", + "notesExport": "Xuất ghi chú", + "notesImportDistinct": "Nhập thành ghi chú riêng", + "notesAvailableOffline": "Có sẵn ngoại tuyến", + "notesIncomplete": "Chưa đầy đủ", + "notesSelected": "Ghi chú đã chọn", + "notesNoHistory": "Không có phiên bản được giữ. Lịch sử có thể bị tắt, hết hạn hoặc bị xóa.", + "notesTextAvailable": "Văn bản có sẵn cục bộ", + "notesLocalVersion": "Phiên bản làm việc cục bộ", + "notesAttachments": "Tệp đính kèm", + "notesSkipped": "Đã bỏ qua", + "notesFailed": "Thất bại", + "notesCompleted": "Hoàn tất", + "notesImportNew": "Bắt đầu nhập mới", + "notesImportResume": "Tiếp tục lần nhập bị gián đoạn", + "notesImportAlreadyImported": "Đã nhập trong thao tác này", + "references": "Tham chiếu", + "referenceIncoming": "Đến", + "referenceOutgoing": "Đi", + "referenceChooseTarget": "Chọn đích liên kết", + "referenceWholeDocument": "Toàn bộ tài liệu", + "referenceSourceLocation": "Vị trí nguồn", + "referenceTargetLocation": "Vị trí đích", + "referenceResolved": "Đã phân giải", + "referenceMissing": "Không tìm thấy", + "referenceAmbiguous": "Không rõ ràng", + "referenceUnavailable": "Không khả dụng trong ngữ cảnh này", + "referenceUnsupported": "Không được hỗ trợ", + "referenceNoPortableId": "Đích này không có URL ghi chú di động đã xác minh." } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 3210e922..b63e57ba 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -4958,5 +4958,97 @@ "nextcloudPossibleCandidate": "可能与规范化后的请求匹配;身份尚未确认。", "nextcloudSelectCandidate": "选择要检查的服务器笔记", "nextcloudCreateSeparate": "创建独立笔记", - "nextcloudCreateSeparateWarning": "首次请求可能已在服务器上创建笔记。此操作将使用保留的本地工作创建独立笔记。不会删除任何疑似重复的笔记。" + "nextcloudCreateSeparateWarning": "首次请求可能已在服务器上创建笔记。此操作将使用保留的本地工作创建独立笔记。不会删除任何疑似重复的笔记。", + "wsSemanticFormats": "语义格式", + "wsUiControl": "界面控件", + "wsFilePath": "文件 / 路径", + "wsUiPath": "界面路径", + "wsDefinitionList": "定义列表", + "wsIncludeContent": "包含可复用内容", + "wsVariableReference": "变量引用", + "wsConvertList": "将列表转换为过程", + "wsElementProperties": "元素属性", + "wsTopicProperties": "主题属性", + "wsCollapsible": "可折叠", + "wsPublishedState": "发布时的初始状态", + "wsExpanded": "展开", + "wsCollapsed": "折叠", + "wsCollapsedTitle": "折叠标题", + "wsTerm": "术语", + "wsListLayout": "列表布局", + "wsSyncGroup": "同步组", + "wsTabKey": "选项卡键", + "wsSwitcherLabel": "切换器标签", + "wsNavigationElements": "导航元素", + "wsNavigationDepth": "导航深度", + "wsAddItem": "添加项目", + "wsStep": "步骤", + "wsPropertyInvalid": "属性值无效", + "wsFilter": "筛选器", + "wsIdentifier": "元素 ID", + "wsTldr": "摘要 (TLDR)", + "wsFullLayout": "全宽", + "wsMediumLayout": "等宽列", + "wsNarrowLayout": "窄术语列", + "wsTitle": "标题", + "wsSwitcherKey": "切换键", + "nextcloudServerSettings": "服务器 Notes 设置", + "nextcloudNotesPath": "笔记文件夹路径", + "nextcloudFileSuffix": "新笔记的文件后缀", + "nextcloudServerSettingsExplanation": "这些服务器 Notes 设置会影响其他客户端。更改路径不会移动文件;更改后缀不会重命名现有笔记。", + "nextcloudSettingsSaved": "服务器设置已保存", + "nextcloudSettingsNormalized": "已保存服务器规范化后的值", + "nextcloudPendingUpload": "附件等待上传", + "nextcloudServerCheck": "上次服务器检查:{time}", + "nextcloudSettingsLoading": "正在加载服务器设置", + "@nextcloudSettingsLoading": {"description": "Accessible progress label while reading server Notes settings."}, + "nextcloudSettingsSaving": "正在保存服务器设置", + "@nextcloudSettingsSaving": {"description": "Accessible progress label while writing server Notes settings."}, + "nextcloudPreviousLocalEdit": "先前的本地编辑", + "nextcloudUsePreviousEdit": "使用先前编辑", + "quickOpen": "快速打开", + "notesAll": "所有笔记", + "notesUncategorized": "未分类", + "notesRecovery": "恢复", + "notesRecentlyEdited": "最近编辑", + "notesOldestEdited": "最早编辑", + "notesTitleAscending": "标题升序", + "notesTitleDescending": "标题降序", + "notesMove": "移至分类", + "notesRemoveFavorite": "取消收藏", + "notesOffline": "设为离线可用", + "notesOfflineRemove": "移除离线保留要求", + "notesIndexing": "正在索引笔记…", + "notesSearchHelp": "词项使用“与”组合。可使用\"引号短语\"、title: 和 category:。", + "notesSourceLocation": "源文件位置", + "notesRecoveryHelp": "仅包括保留的本地笔记和本地历史。无法访问服务器回收站或历史。", + "notesImport": "导入笔记", + "notesExport": "导出笔记", + "notesImportDistinct": "导入为独立笔记", + "notesAvailableOffline": "离线可用", + "notesIncomplete": "不完整", + "notesSelected": "已选笔记", + "notesNoHistory": "没有保留的版本。历史可能已禁用、过期或清除。", + "notesTextAvailable": "文本已在本地可用", + "notesLocalVersion": "本地工作版本", + "notesAttachments": "附件", + "notesSkipped": "已跳过", + "notesFailed": "失败", + "notesCompleted": "已完成", + "notesImportNew": "开始新导入", + "notesImportResume": "继续中断的导入", + "notesImportAlreadyImported": "已在此操作中导入", + "references": "引用", + "referenceIncoming": "传入", + "referenceOutgoing": "传出", + "referenceChooseTarget": "选择链接目标", + "referenceWholeDocument": "整个文档", + "referenceSourceLocation": "源位置", + "referenceTargetLocation": "目标位置", + "referenceResolved": "已解析", + "referenceMissing": "缺失", + "referenceAmbiguous": "不明确", + "referenceUnavailable": "当前上下文中不可用", + "referenceUnsupported": "不支持", + "referenceNoPortableId": "此目标没有经过验证的可移植笔记网址。" } diff --git a/lib/l10n/app_zh_CN.arb b/lib/l10n/app_zh_CN.arb index d6863563..894189ca 100644 --- a/lib/l10n/app_zh_CN.arb +++ b/lib/l10n/app_zh_CN.arb @@ -4958,5 +4958,97 @@ "nextcloudPossibleCandidate": "可能与规范化后的请求匹配;身份尚未确认。", "nextcloudSelectCandidate": "选择要检查的服务器笔记", "nextcloudCreateSeparate": "创建独立笔记", - "nextcloudCreateSeparateWarning": "首次请求可能已在服务器上创建笔记。此操作将使用保留的本地工作创建独立笔记。不会删除任何疑似重复的笔记。" + "nextcloudCreateSeparateWarning": "首次请求可能已在服务器上创建笔记。此操作将使用保留的本地工作创建独立笔记。不会删除任何疑似重复的笔记。", + "wsSemanticFormats": "语义格式", + "wsUiControl": "界面控件", + "wsFilePath": "文件 / 路径", + "wsUiPath": "界面路径", + "wsDefinitionList": "定义列表", + "wsIncludeContent": "包含可复用内容", + "wsVariableReference": "变量引用", + "wsConvertList": "将列表转换为过程", + "wsElementProperties": "元素属性", + "wsTopicProperties": "主题属性", + "wsCollapsible": "可折叠", + "wsPublishedState": "发布时的初始状态", + "wsExpanded": "展开", + "wsCollapsed": "折叠", + "wsCollapsedTitle": "折叠标题", + "wsTerm": "术语", + "wsListLayout": "列表布局", + "wsSyncGroup": "同步组", + "wsTabKey": "选项卡键", + "wsSwitcherLabel": "切换器标签", + "wsNavigationElements": "导航元素", + "wsNavigationDepth": "导航深度", + "wsAddItem": "添加项目", + "wsStep": "步骤", + "wsPropertyInvalid": "属性值无效", + "wsFilter": "筛选器", + "wsIdentifier": "元素 ID", + "wsTldr": "摘要 (TLDR)", + "wsFullLayout": "全宽", + "wsMediumLayout": "等宽列", + "wsNarrowLayout": "窄术语列", + "wsTitle": "标题", + "wsSwitcherKey": "切换键", + "nextcloudServerSettings": "服务器 Notes 设置", + "nextcloudNotesPath": "笔记文件夹路径", + "nextcloudFileSuffix": "新笔记的文件后缀", + "nextcloudServerSettingsExplanation": "这些服务器 Notes 设置会影响其他客户端。更改路径不会移动文件;更改后缀不会重命名现有笔记。", + "nextcloudSettingsSaved": "服务器设置已保存", + "nextcloudSettingsNormalized": "已保存服务器规范化后的值", + "nextcloudPendingUpload": "附件等待上传", + "nextcloudServerCheck": "上次服务器检查:{time}", + "nextcloudSettingsLoading": "正在加载服务器设置", + "@nextcloudSettingsLoading": {"description": "Accessible progress label while reading server Notes settings."}, + "nextcloudSettingsSaving": "正在保存服务器设置", + "@nextcloudSettingsSaving": {"description": "Accessible progress label while writing server Notes settings."}, + "nextcloudPreviousLocalEdit": "先前的本地编辑", + "nextcloudUsePreviousEdit": "使用先前编辑", + "quickOpen": "快速打开", + "notesAll": "所有笔记", + "notesUncategorized": "未分类", + "notesRecovery": "恢复", + "notesRecentlyEdited": "最近编辑", + "notesOldestEdited": "最早编辑", + "notesTitleAscending": "标题升序", + "notesTitleDescending": "标题降序", + "notesMove": "移至分类", + "notesRemoveFavorite": "取消收藏", + "notesOffline": "设为离线可用", + "notesOfflineRemove": "移除离线保留要求", + "notesIndexing": "正在索引笔记…", + "notesSearchHelp": "词项使用“与”组合。可使用\"引号短语\"、title: 和 category:。", + "notesSourceLocation": "源文件位置", + "notesRecoveryHelp": "仅包括保留的本地笔记和本地历史。无法访问服务器回收站或历史。", + "notesImport": "导入笔记", + "notesExport": "导出笔记", + "notesImportDistinct": "导入为独立笔记", + "notesAvailableOffline": "离线可用", + "notesIncomplete": "不完整", + "notesSelected": "已选笔记", + "notesNoHistory": "没有保留的版本。历史可能已禁用、过期或清除。", + "notesTextAvailable": "文本已在本地可用", + "notesLocalVersion": "本地工作版本", + "notesAttachments": "附件", + "notesSkipped": "已跳过", + "notesFailed": "失败", + "notesCompleted": "已完成", + "notesImportNew": "开始新导入", + "notesImportResume": "继续中断的导入", + "notesImportAlreadyImported": "已在此操作中导入", + "references": "引用", + "referenceIncoming": "传入", + "referenceOutgoing": "传出", + "referenceChooseTarget": "选择链接目标", + "referenceWholeDocument": "整个文档", + "referenceSourceLocation": "源位置", + "referenceTargetLocation": "目标位置", + "referenceResolved": "已解析", + "referenceMissing": "缺失", + "referenceAmbiguous": "不明确", + "referenceUnavailable": "当前上下文中不可用", + "referenceUnsupported": "不支持", + "referenceNoPortableId": "此目标没有经过验证的可移植笔记网址。" } diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index 5b1d05a6..9adf9610 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -8117,6 +8117,546 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'The first request may already have created a server note. This creates a separate note with your retained local work. No suspected duplicate will be deleted.'** String get nextcloudCreateSeparateWarning; + + /// No description provided for @wsSemanticFormats. + /// + /// In en, this message translates to: + /// **'Semantic formatting'** + String get wsSemanticFormats; + + /// No description provided for @wsUiControl. + /// + /// In en, this message translates to: + /// **'UI Control'** + String get wsUiControl; + + /// No description provided for @wsFilePath. + /// + /// In en, this message translates to: + /// **'File / Path'** + String get wsFilePath; + + /// No description provided for @wsUiPath. + /// + /// In en, this message translates to: + /// **'UI Path'** + String get wsUiPath; + + /// No description provided for @wsDefinitionList. + /// + /// In en, this message translates to: + /// **'Definition List'** + String get wsDefinitionList; + + /// No description provided for @wsIncludeContent. + /// + /// In en, this message translates to: + /// **'Include Reusable Content'** + String get wsIncludeContent; + + /// No description provided for @wsVariableReference. + /// + /// In en, this message translates to: + /// **'Variable Reference'** + String get wsVariableReference; + + /// No description provided for @wsConvertList. + /// + /// In en, this message translates to: + /// **'Convert List to Procedure'** + String get wsConvertList; + + /// No description provided for @wsElementProperties. + /// + /// In en, this message translates to: + /// **'Element Properties'** + String get wsElementProperties; + + /// No description provided for @wsTopicProperties. + /// + /// In en, this message translates to: + /// **'Topic Properties'** + String get wsTopicProperties; + + /// No description provided for @wsCollapsible. + /// + /// In en, this message translates to: + /// **'Collapsible'** + String get wsCollapsible; + + /// No description provided for @wsPublishedState. + /// + /// In en, this message translates to: + /// **'Published initial state'** + String get wsPublishedState; + + /// No description provided for @wsExpanded. + /// + /// In en, this message translates to: + /// **'Expanded'** + String get wsExpanded; + + /// No description provided for @wsCollapsed. + /// + /// In en, this message translates to: + /// **'Collapsed'** + String get wsCollapsed; + + /// No description provided for @wsCollapsedTitle. + /// + /// In en, this message translates to: + /// **'Collapsed title'** + String get wsCollapsedTitle; + + /// No description provided for @wsTerm. + /// + /// In en, this message translates to: + /// **'Term'** + String get wsTerm; + + /// No description provided for @wsListLayout. + /// + /// In en, this message translates to: + /// **'List layout'** + String get wsListLayout; + + /// No description provided for @wsSyncGroup. + /// + /// In en, this message translates to: + /// **'Synchronization group'** + String get wsSyncGroup; + + /// No description provided for @wsTabKey. + /// + /// In en, this message translates to: + /// **'Tab key'** + String get wsTabKey; + + /// No description provided for @wsSwitcherLabel. + /// + /// In en, this message translates to: + /// **'Switcher label'** + String get wsSwitcherLabel; + + /// No description provided for @wsNavigationElements. + /// + /// In en, this message translates to: + /// **'Navigation elements'** + String get wsNavigationElements; + + /// No description provided for @wsNavigationDepth. + /// + /// In en, this message translates to: + /// **'Navigation depth'** + String get wsNavigationDepth; + + /// No description provided for @wsAddItem. + /// + /// In en, this message translates to: + /// **'Add item'** + String get wsAddItem; + + /// No description provided for @wsStep. + /// + /// In en, this message translates to: + /// **'Step'** + String get wsStep; + + /// No description provided for @wsPropertyInvalid. + /// + /// In en, this message translates to: + /// **'Invalid property value'** + String get wsPropertyInvalid; + + /// No description provided for @wsFilter. + /// + /// In en, this message translates to: + /// **'Filter'** + String get wsFilter; + + /// No description provided for @wsIdentifier. + /// + /// In en, this message translates to: + /// **'Element ID'** + String get wsIdentifier; + + /// No description provided for @wsTldr. + /// + /// In en, this message translates to: + /// **'TLDR'** + String get wsTldr; + + /// No description provided for @wsFullLayout. + /// + /// In en, this message translates to: + /// **'Full width'** + String get wsFullLayout; + + /// No description provided for @wsMediumLayout. + /// + /// In en, this message translates to: + /// **'Equal columns'** + String get wsMediumLayout; + + /// No description provided for @wsNarrowLayout. + /// + /// In en, this message translates to: + /// **'Narrow term'** + String get wsNarrowLayout; + + /// No description provided for @wsTitle. + /// + /// In en, this message translates to: + /// **'Title'** + String get wsTitle; + + /// No description provided for @wsSwitcherKey. + /// + /// In en, this message translates to: + /// **'Switcher key'** + String get wsSwitcherKey; + + /// No description provided for @nextcloudServerSettings. + /// + /// In en, this message translates to: + /// **'Server Notes settings'** + String get nextcloudServerSettings; + + /// No description provided for @nextcloudNotesPath. + /// + /// In en, this message translates to: + /// **'Notes folder path'** + String get nextcloudNotesPath; + + /// No description provided for @nextcloudFileSuffix. + /// + /// In en, this message translates to: + /// **'New-note file suffix'** + String get nextcloudFileSuffix; + + /// No description provided for @nextcloudServerSettingsExplanation. + /// + /// In en, this message translates to: + /// **'These server Notes settings affect other clients. Changing the path does not migrate files; changing the suffix does not rename existing notes.'** + String get nextcloudServerSettingsExplanation; + + /// No description provided for @nextcloudSettingsSaved. + /// + /// In en, this message translates to: + /// **'Server settings saved'** + String get nextcloudSettingsSaved; + + /// No description provided for @nextcloudSettingsNormalized. + /// + /// In en, this message translates to: + /// **'Saved with server-normalized values'** + String get nextcloudSettingsNormalized; + + /// No description provided for @nextcloudPendingUpload. + /// + /// In en, this message translates to: + /// **'Attachment upload pending'** + String get nextcloudPendingUpload; + + /// No description provided for @nextcloudServerCheck. + /// + /// In en, this message translates to: + /// **'Last server check: {time}'** + String nextcloudServerCheck(String time); + + /// Accessible progress label while reading server Notes settings. + /// + /// In en, this message translates to: + /// **'Loading server settings'** + String get nextcloudSettingsLoading; + + /// Accessible progress label while writing server Notes settings. + /// + /// In en, this message translates to: + /// **'Saving server settings'** + String get nextcloudSettingsSaving; + + /// Label for the competing accepted local metadata edit, distinct from server state. + /// + /// In en, this message translates to: + /// **'Previous local edit'** + String get nextcloudPreviousLocalEdit; + + /// Conflict action that keeps the exact previously reviewed local metadata values. + /// + /// In en, this message translates to: + /// **'Use previous edit'** + String get nextcloudUsePreviousEdit; + + /// No description provided for @quickOpen. + /// + /// In en, this message translates to: + /// **'Quick Open'** + String get quickOpen; + + /// No description provided for @notesAll. + /// + /// In en, this message translates to: + /// **'All notes'** + String get notesAll; + + /// No description provided for @notesUncategorized. + /// + /// In en, this message translates to: + /// **'Uncategorized'** + String get notesUncategorized; + + /// No description provided for @notesRecovery. + /// + /// In en, this message translates to: + /// **'Recovery'** + String get notesRecovery; + + /// No description provided for @notesRecentlyEdited. + /// + /// In en, this message translates to: + /// **'Recently edited'** + String get notesRecentlyEdited; + + /// No description provided for @notesOldestEdited. + /// + /// In en, this message translates to: + /// **'Oldest edited'** + String get notesOldestEdited; + + /// No description provided for @notesTitleAscending. + /// + /// In en, this message translates to: + /// **'Title A–Z'** + String get notesTitleAscending; + + /// No description provided for @notesTitleDescending. + /// + /// In en, this message translates to: + /// **'Title Z–A'** + String get notesTitleDescending; + + /// No description provided for @notesMove. + /// + /// In en, this message translates to: + /// **'Move to category'** + String get notesMove; + + /// No description provided for @notesRemoveFavorite. + /// + /// In en, this message translates to: + /// **'Remove favorite'** + String get notesRemoveFavorite; + + /// No description provided for @notesOffline. + /// + /// In en, this message translates to: + /// **'Make available offline'** + String get notesOffline; + + /// No description provided for @notesOfflineRemove. + /// + /// In en, this message translates to: + /// **'Remove offline requirement'** + String get notesOfflineRemove; + + /// No description provided for @notesIndexing. + /// + /// In en, this message translates to: + /// **'Indexing notes…'** + String get notesIndexing; + + /// No description provided for @notesSearchHelp. + /// + /// In en, this message translates to: + /// **'Terms use AND. Use \"quoted phrases\", title: and category:.'** + String get notesSearchHelp; + + /// No description provided for @notesSourceLocation. + /// + /// In en, this message translates to: + /// **'Source location'** + String get notesSourceLocation; + + /// No description provided for @notesRecoveryHelp. + /// + /// In en, this message translates to: + /// **'Retained local notes and Local History only. Server trash and history are unavailable.'** + String get notesRecoveryHelp; + + /// No description provided for @notesImport. + /// + /// In en, this message translates to: + /// **'Import notes'** + String get notesImport; + + /// No description provided for @notesExport. + /// + /// In en, this message translates to: + /// **'Export notes'** + String get notesExport; + + /// No description provided for @notesImportDistinct. + /// + /// In en, this message translates to: + /// **'Import as a distinct note'** + String get notesImportDistinct; + + /// No description provided for @notesAvailableOffline. + /// + /// In en, this message translates to: + /// **'Available offline'** + String get notesAvailableOffline; + + /// No description provided for @notesIncomplete. + /// + /// In en, this message translates to: + /// **'Incomplete'** + String get notesIncomplete; + + /// No description provided for @notesSelected. + /// + /// In en, this message translates to: + /// **'Selected notes'** + String get notesSelected; + + /// No description provided for @notesNoHistory. + /// + /// In en, this message translates to: + /// **'No retained revisions. History may be disabled, expired or cleared.'** + String get notesNoHistory; + + /// No description provided for @notesTextAvailable. + /// + /// In en, this message translates to: + /// **'Text available locally'** + String get notesTextAvailable; + + /// No description provided for @notesLocalVersion. + /// + /// In en, this message translates to: + /// **'Local working version'** + String get notesLocalVersion; + + /// No description provided for @notesAttachments. + /// + /// In en, this message translates to: + /// **'Attachments'** + String get notesAttachments; + + /// No description provided for @notesSkipped. + /// + /// In en, this message translates to: + /// **'Skipped'** + String get notesSkipped; + + /// No description provided for @notesFailed. + /// + /// In en, this message translates to: + /// **'Failed'** + String get notesFailed; + + /// No description provided for @notesCompleted. + /// + /// In en, this message translates to: + /// **'Completed'** + String get notesCompleted; + + /// No description provided for @notesImportNew. + /// + /// In en, this message translates to: + /// **'Start a new import'** + String get notesImportNew; + + /// No description provided for @notesImportResume. + /// + /// In en, this message translates to: + /// **'Resume interrupted import'** + String get notesImportResume; + + /// No description provided for @notesImportAlreadyImported. + /// + /// In en, this message translates to: + /// **'Already imported in this operation'** + String get notesImportAlreadyImported; + + /// No description provided for @references. + /// + /// In en, this message translates to: + /// **'References'** + String get references; + + /// No description provided for @referenceIncoming. + /// + /// In en, this message translates to: + /// **'Incoming'** + String get referenceIncoming; + + /// No description provided for @referenceOutgoing. + /// + /// In en, this message translates to: + /// **'Outgoing'** + String get referenceOutgoing; + + /// No description provided for @referenceChooseTarget. + /// + /// In en, this message translates to: + /// **'Choose link target'** + String get referenceChooseTarget; + + /// No description provided for @referenceWholeDocument. + /// + /// In en, this message translates to: + /// **'Whole document'** + String get referenceWholeDocument; + + /// No description provided for @referenceSourceLocation. + /// + /// In en, this message translates to: + /// **'Source location'** + String get referenceSourceLocation; + + /// No description provided for @referenceTargetLocation. + /// + /// In en, this message translates to: + /// **'Target location'** + String get referenceTargetLocation; + + /// No description provided for @referenceResolved. + /// + /// In en, this message translates to: + /// **'Resolved'** + String get referenceResolved; + + /// No description provided for @referenceMissing. + /// + /// In en, this message translates to: + /// **'Missing'** + String get referenceMissing; + + /// No description provided for @referenceAmbiguous. + /// + /// In en, this message translates to: + /// **'Ambiguous'** + String get referenceAmbiguous; + + /// No description provided for @referenceUnavailable. + /// + /// In en, this message translates to: + /// **'Unavailable in this context'** + String get referenceUnavailable; + + /// No description provided for @referenceUnsupported. + /// + /// In en, this message translates to: + /// **'Unsupported'** + String get referenceUnsupported; + + /// No description provided for @referenceNoPortableId. + /// + /// In en, this message translates to: + /// **'This target has no verified portable note URL.'** + String get referenceNoPortableId; } class _AppLocalizationsDelegate diff --git a/lib/l10n/generated/app_localizations_ar.dart b/lib/l10n/generated/app_localizations_ar.dart index 6a9cc5f3..50752c32 100644 --- a/lib/l10n/generated/app_localizations_ar.dart +++ b/lib/l10n/generated/app_localizations_ar.dart @@ -4818,4 +4818,282 @@ class AppLocalizationsAr extends AppLocalizations { @override String get nextcloudCreateSeparateWarning => 'قد يكون الطلب الأول قد أنشأ ملاحظة على الخادم بالفعل. ينشئ هذا ملاحظة منفصلة بعملك المحلي المحفوظ. لن تُحذف أي ملاحظة يُشتبه بأنها مكررة.'; + + @override + String get wsSemanticFormats => 'تنسيق دلالي'; + + @override + String get wsUiControl => 'عنصر تحكم في الواجهة'; + + @override + String get wsFilePath => 'ملف / مسار'; + + @override + String get wsUiPath => 'مسار الواجهة'; + + @override + String get wsDefinitionList => 'قائمة تعريفات'; + + @override + String get wsIncludeContent => 'تضمين محتوى قابل لإعادة الاستخدام'; + + @override + String get wsVariableReference => 'مرجع متغير'; + + @override + String get wsConvertList => 'تحويل القائمة إلى إجراء'; + + @override + String get wsElementProperties => 'خصائص العنصر'; + + @override + String get wsTopicProperties => 'خصائص الموضوع'; + + @override + String get wsCollapsible => 'قابل للطي'; + + @override + String get wsPublishedState => 'الحالة الأولية عند النشر'; + + @override + String get wsExpanded => 'موسع'; + + @override + String get wsCollapsed => 'مطوي'; + + @override + String get wsCollapsedTitle => 'العنوان عند الطي'; + + @override + String get wsTerm => 'المصطلح'; + + @override + String get wsListLayout => 'تخطيط القائمة'; + + @override + String get wsSyncGroup => 'مجموعة المزامنة'; + + @override + String get wsTabKey => 'مفتاح علامة التبويب'; + + @override + String get wsSwitcherLabel => 'تسمية المبدل'; + + @override + String get wsNavigationElements => 'عناصر التنقل'; + + @override + String get wsNavigationDepth => 'عمق التنقل'; + + @override + String get wsAddItem => 'إضافة عنصر'; + + @override + String get wsStep => 'خطوة'; + + @override + String get wsPropertyInvalid => 'قيمة خاصية غير صالحة'; + + @override + String get wsFilter => 'عامل التصفية'; + + @override + String get wsIdentifier => 'معرّف العنصر'; + + @override + String get wsTldr => 'ملخّص (TLDR)'; + + @override + String get wsFullLayout => 'عرض كامل'; + + @override + String get wsMediumLayout => 'أعمدة متساوية'; + + @override + String get wsNarrowLayout => 'مصطلح ضيق'; + + @override + String get wsTitle => 'العنوان'; + + @override + String get wsSwitcherKey => 'مفتاح المبدّل'; + + @override + String get nextcloudServerSettings => 'إعدادات Notes على الخادم'; + + @override + String get nextcloudNotesPath => 'مسار مجلد الملاحظات'; + + @override + String get nextcloudFileSuffix => 'لاحقة ملفات الملاحظات الجديدة'; + + @override + String get nextcloudServerSettingsExplanation => + 'تؤثر إعدادات Notes هذه على العملاء الآخرين. تغيير المسار لا ينقل الملفات؛ وتغيير اللاحقة لا يعيد تسمية الملاحظات الحالية.'; + + @override + String get nextcloudSettingsSaved => 'حُفظت إعدادات الخادم'; + + @override + String get nextcloudSettingsNormalized => + 'حُفظت القيم بعد توحيدها على الخادم'; + + @override + String get nextcloudPendingUpload => 'رفع المرفق قيد الانتظار'; + + @override + String nextcloudServerCheck(String time) { + return 'آخر فحص للخادم: $time'; + } + + @override + String get nextcloudSettingsLoading => 'جارٍ تحميل إعدادات الخادم'; + + @override + String get nextcloudSettingsSaving => 'جارٍ حفظ إعدادات الخادم'; + + @override + String get nextcloudPreviousLocalEdit => 'التعديل المحلي السابق'; + + @override + String get nextcloudUsePreviousEdit => 'استخدام التعديل السابق'; + + @override + String get quickOpen => 'فتح سريع'; + + @override + String get notesAll => 'كل الملاحظات'; + + @override + String get notesUncategorized => 'بلا تصنيف'; + + @override + String get notesRecovery => 'استعادة'; + + @override + String get notesRecentlyEdited => 'المعدلة مؤخراً'; + + @override + String get notesOldestEdited => 'الأقدم تعديلاً'; + + @override + String get notesTitleAscending => 'العنوان تصاعدياً'; + + @override + String get notesTitleDescending => 'العنوان تنازلياً'; + + @override + String get notesMove => 'نقل إلى تصنيف'; + + @override + String get notesRemoveFavorite => 'إزالة من المفضلة'; + + @override + String get notesOffline => 'إتاحة دون اتصال'; + + @override + String get notesOfflineRemove => 'إزالة الاحتفاظ دون اتصال'; + + @override + String get notesIndexing => 'جارٍ فهرسة الملاحظات…'; + + @override + String get notesSearchHelp => + 'تُجمع الكلمات باستخدام AND. استخدم \"عبارات مقتبسة\" و title: و category:.'; + + @override + String get notesSourceLocation => 'الموضع في المصدر'; + + @override + String get notesRecoveryHelp => + 'الملاحظات المحلية المحتفظ بها والسجل المحلي فقط. سلة المحذوفات وسجل الخادم غير متاحين.'; + + @override + String get notesImport => 'استيراد ملاحظات'; + + @override + String get notesExport => 'تصدير ملاحظات'; + + @override + String get notesImportDistinct => 'استيراد كملاحظة مستقلة'; + + @override + String get notesAvailableOffline => 'متاح دون اتصال'; + + @override + String get notesIncomplete => 'غير مكتمل'; + + @override + String get notesSelected => 'الملاحظات المحددة'; + + @override + String get notesNoHistory => + 'لا توجد مراجعات محفوظة. قد يكون السجل معطلاً أو منتهياً أو ممسوحاً.'; + + @override + String get notesTextAvailable => 'النص متاح محليًا'; + + @override + String get notesLocalVersion => 'نسخة العمل المحلية'; + + @override + String get notesAttachments => 'المرفقات'; + + @override + String get notesSkipped => 'تم التخطي'; + + @override + String get notesFailed => 'فشل'; + + @override + String get notesCompleted => 'مكتمل'; + + @override + String get notesImportNew => 'بدء استيراد جديد'; + + @override + String get notesImportResume => 'استئناف الاستيراد المنقطع'; + + @override + String get notesImportAlreadyImported => 'تم الاستيراد بالفعل في هذه العملية'; + + @override + String get references => 'المراجع'; + + @override + String get referenceIncoming => 'الواردة'; + + @override + String get referenceOutgoing => 'الصادرة'; + + @override + String get referenceChooseTarget => 'اختر هدف الرابط'; + + @override + String get referenceWholeDocument => 'المستند بالكامل'; + + @override + String get referenceSourceLocation => 'موقع المصدر'; + + @override + String get referenceTargetLocation => 'موقع الهدف'; + + @override + String get referenceResolved => 'تم الحل'; + + @override + String get referenceMissing => 'مفقود'; + + @override + String get referenceAmbiguous => 'ملتبس'; + + @override + String get referenceUnavailable => 'غير متاح في هذا السياق'; + + @override + String get referenceUnsupported => 'غير مدعوم'; + + @override + String get referenceNoPortableId => + 'ليس لهذا الهدف عنوان URL محمول ومُتحقَّق منه للملاحظة.'; } diff --git a/lib/l10n/generated/app_localizations_de.dart b/lib/l10n/generated/app_localizations_de.dart index 81c2afce..9cf4b51c 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -4831,4 +4831,284 @@ class AppLocalizationsDe extends AppLocalizations { @override String get nextcloudCreateSeparateWarning => 'Die erste Anfrage hat möglicherweise bereits eine Servernotiz erstellt. Dies erstellt eine separate Notiz mit Ihrer behaltenen lokalen Arbeit. Vermutete Duplikate werden nicht gelöscht.'; + + @override + String get wsSemanticFormats => 'Semantische Formatierung'; + + @override + String get wsUiControl => 'UI-Steuerelement'; + + @override + String get wsFilePath => 'Datei / Pfad'; + + @override + String get wsUiPath => 'UI-Pfad'; + + @override + String get wsDefinitionList => 'Definitionsliste'; + + @override + String get wsIncludeContent => 'Wiederverwendbaren Inhalt einbinden'; + + @override + String get wsVariableReference => 'Variablenreferenz'; + + @override + String get wsConvertList => 'Liste in Prozedur umwandeln'; + + @override + String get wsElementProperties => 'Elementeigenschaften'; + + @override + String get wsTopicProperties => 'Themeneigenschaften'; + + @override + String get wsCollapsible => 'Einklappbar'; + + @override + String get wsPublishedState => 'Anfangszustand bei Veröffentlichung'; + + @override + String get wsExpanded => 'Ausgeklappt'; + + @override + String get wsCollapsed => 'Eingeklappt'; + + @override + String get wsCollapsedTitle => 'Titel im eingeklappten Zustand'; + + @override + String get wsTerm => 'Begriff'; + + @override + String get wsListLayout => 'Listenlayout'; + + @override + String get wsSyncGroup => 'Synchronisierungsgruppe'; + + @override + String get wsTabKey => 'Registerschlüssel'; + + @override + String get wsSwitcherLabel => 'Beschriftung des Umschalters'; + + @override + String get wsNavigationElements => 'Navigationselemente'; + + @override + String get wsNavigationDepth => 'Navigationstiefe'; + + @override + String get wsAddItem => 'Element hinzufügen'; + + @override + String get wsStep => 'Schritt'; + + @override + String get wsPropertyInvalid => 'Ungültiger Eigenschaftswert'; + + @override + String get wsFilter => 'Inhaltsfilter'; + + @override + String get wsIdentifier => 'Element-ID'; + + @override + String get wsTldr => 'Kurzfassung (TLDR)'; + + @override + String get wsFullLayout => 'Volle Breite'; + + @override + String get wsMediumLayout => 'Gleiche Spalten'; + + @override + String get wsNarrowLayout => 'Schmale Begriffsspalte'; + + @override + String get wsTitle => 'Titel'; + + @override + String get wsSwitcherKey => 'Umschaltschlüssel'; + + @override + String get nextcloudServerSettings => 'Notes-Einstellungen auf dem Server'; + + @override + String get nextcloudNotesPath => 'Pfad des Notizordners'; + + @override + String get nextcloudFileSuffix => 'Dateiendung neuer Notizen'; + + @override + String get nextcloudServerSettingsExplanation => + 'Diese Notes-Einstellungen gelten auch für andere Clients. Ein Pfadwechsel verschiebt keine Dateien; eine neue Endung benennt bestehende Notizen nicht um.'; + + @override + String get nextcloudSettingsSaved => 'Servereinstellungen gespeichert'; + + @override + String get nextcloudSettingsNormalized => + 'Mit vom Server normalisierten Werten gespeichert'; + + @override + String get nextcloudPendingUpload => 'Anhang wartet auf Upload'; + + @override + String nextcloudServerCheck(String time) { + return 'Letzte Serverprüfung: $time'; + } + + @override + String get nextcloudSettingsLoading => 'Servereinstellungen werden geladen'; + + @override + String get nextcloudSettingsSaving => + 'Servereinstellungen werden gespeichert'; + + @override + String get nextcloudPreviousLocalEdit => 'Vorherige lokale Änderung'; + + @override + String get nextcloudUsePreviousEdit => 'Vorherige Änderung verwenden'; + + @override + String get quickOpen => 'Schnell öffnen'; + + @override + String get notesAll => 'Alle Notizen'; + + @override + String get notesUncategorized => 'Ohne Kategorie'; + + @override + String get notesRecovery => 'Wiederherstellung'; + + @override + String get notesRecentlyEdited => 'Zuletzt bearbeitet'; + + @override + String get notesOldestEdited => 'Älteste Bearbeitung'; + + @override + String get notesTitleAscending => 'Titel A–Z'; + + @override + String get notesTitleDescending => 'Titel Z–A'; + + @override + String get notesMove => 'In Kategorie verschieben'; + + @override + String get notesRemoveFavorite => 'Favorit entfernen'; + + @override + String get notesOffline => 'Offline verfügbar machen'; + + @override + String get notesOfflineRemove => 'Offline-Aufbewahrung entfernen'; + + @override + String get notesIndexing => 'Notizen werden indiziert…'; + + @override + String get notesSearchHelp => + 'Begriffe verwenden UND. Verwenden Sie \"Wortgruppen\", title: und category:.'; + + @override + String get notesSourceLocation => 'Quellposition'; + + @override + String get notesRecoveryHelp => + 'Nur lokal aufbewahrte Notizen und lokaler Verlauf. Server-Papierkorb und Server-Verlauf sind nicht verfügbar.'; + + @override + String get notesImport => 'Notizen importieren'; + + @override + String get notesExport => 'Notizen exportieren'; + + @override + String get notesImportDistinct => 'Als eigene Notiz importieren'; + + @override + String get notesAvailableOffline => 'Offline verfügbar'; + + @override + String get notesIncomplete => 'Unvollständig'; + + @override + String get notesSelected => 'Ausgewählte Notizen'; + + @override + String get notesNoHistory => + 'Keine aufbewahrten Versionen. Der Verlauf kann deaktiviert, abgelaufen oder gelöscht sein.'; + + @override + String get notesTextAvailable => 'Text lokal verfügbar'; + + @override + String get notesLocalVersion => 'Lokale Arbeitsversion'; + + @override + String get notesAttachments => 'Anhänge'; + + @override + String get notesSkipped => 'Übersprungen'; + + @override + String get notesFailed => 'Fehlgeschlagen'; + + @override + String get notesCompleted => 'Abgeschlossen'; + + @override + String get notesImportNew => 'Neuen Import starten'; + + @override + String get notesImportResume => 'Unterbrochenen Import fortsetzen'; + + @override + String get notesImportAlreadyImported => + 'In diesem Vorgang bereits importiert'; + + @override + String get references => 'Verweise'; + + @override + String get referenceIncoming => 'Eingehend'; + + @override + String get referenceOutgoing => 'Ausgehend'; + + @override + String get referenceChooseTarget => 'Linkziel auswählen'; + + @override + String get referenceWholeDocument => 'Gesamtes Dokument'; + + @override + String get referenceSourceLocation => 'Quellposition'; + + @override + String get referenceTargetLocation => 'Zielposition'; + + @override + String get referenceResolved => 'Aufgelöst'; + + @override + String get referenceMissing => 'Fehlt'; + + @override + String get referenceAmbiguous => 'Mehrdeutig'; + + @override + String get referenceUnavailable => 'In diesem Kontext nicht verfügbar'; + + @override + String get referenceUnsupported => 'Nicht unterstützt'; + + @override + String get referenceNoPortableId => + 'Dieses Ziel hat keine bestätigte portable Notiz-URL.'; } diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index ae6f199d..319e3c7f 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -4802,4 +4802,282 @@ class AppLocalizationsEn extends AppLocalizations { @override String get nextcloudCreateSeparateWarning => 'The first request may already have created a server note. This creates a separate note with your retained local work. No suspected duplicate will be deleted.'; + + @override + String get wsSemanticFormats => 'Semantic formatting'; + + @override + String get wsUiControl => 'UI Control'; + + @override + String get wsFilePath => 'File / Path'; + + @override + String get wsUiPath => 'UI Path'; + + @override + String get wsDefinitionList => 'Definition List'; + + @override + String get wsIncludeContent => 'Include Reusable Content'; + + @override + String get wsVariableReference => 'Variable Reference'; + + @override + String get wsConvertList => 'Convert List to Procedure'; + + @override + String get wsElementProperties => 'Element Properties'; + + @override + String get wsTopicProperties => 'Topic Properties'; + + @override + String get wsCollapsible => 'Collapsible'; + + @override + String get wsPublishedState => 'Published initial state'; + + @override + String get wsExpanded => 'Expanded'; + + @override + String get wsCollapsed => 'Collapsed'; + + @override + String get wsCollapsedTitle => 'Collapsed title'; + + @override + String get wsTerm => 'Term'; + + @override + String get wsListLayout => 'List layout'; + + @override + String get wsSyncGroup => 'Synchronization group'; + + @override + String get wsTabKey => 'Tab key'; + + @override + String get wsSwitcherLabel => 'Switcher label'; + + @override + String get wsNavigationElements => 'Navigation elements'; + + @override + String get wsNavigationDepth => 'Navigation depth'; + + @override + String get wsAddItem => 'Add item'; + + @override + String get wsStep => 'Step'; + + @override + String get wsPropertyInvalid => 'Invalid property value'; + + @override + String get wsFilter => 'Filter'; + + @override + String get wsIdentifier => 'Element ID'; + + @override + String get wsTldr => 'TLDR'; + + @override + String get wsFullLayout => 'Full width'; + + @override + String get wsMediumLayout => 'Equal columns'; + + @override + String get wsNarrowLayout => 'Narrow term'; + + @override + String get wsTitle => 'Title'; + + @override + String get wsSwitcherKey => 'Switcher key'; + + @override + String get nextcloudServerSettings => 'Server Notes settings'; + + @override + String get nextcloudNotesPath => 'Notes folder path'; + + @override + String get nextcloudFileSuffix => 'New-note file suffix'; + + @override + String get nextcloudServerSettingsExplanation => + 'These server Notes settings affect other clients. Changing the path does not migrate files; changing the suffix does not rename existing notes.'; + + @override + String get nextcloudSettingsSaved => 'Server settings saved'; + + @override + String get nextcloudSettingsNormalized => + 'Saved with server-normalized values'; + + @override + String get nextcloudPendingUpload => 'Attachment upload pending'; + + @override + String nextcloudServerCheck(String time) { + return 'Last server check: $time'; + } + + @override + String get nextcloudSettingsLoading => 'Loading server settings'; + + @override + String get nextcloudSettingsSaving => 'Saving server settings'; + + @override + String get nextcloudPreviousLocalEdit => 'Previous local edit'; + + @override + String get nextcloudUsePreviousEdit => 'Use previous edit'; + + @override + String get quickOpen => 'Quick Open'; + + @override + String get notesAll => 'All notes'; + + @override + String get notesUncategorized => 'Uncategorized'; + + @override + String get notesRecovery => 'Recovery'; + + @override + String get notesRecentlyEdited => 'Recently edited'; + + @override + String get notesOldestEdited => 'Oldest edited'; + + @override + String get notesTitleAscending => 'Title A–Z'; + + @override + String get notesTitleDescending => 'Title Z–A'; + + @override + String get notesMove => 'Move to category'; + + @override + String get notesRemoveFavorite => 'Remove favorite'; + + @override + String get notesOffline => 'Make available offline'; + + @override + String get notesOfflineRemove => 'Remove offline requirement'; + + @override + String get notesIndexing => 'Indexing notes…'; + + @override + String get notesSearchHelp => + 'Terms use AND. Use \"quoted phrases\", title: and category:.'; + + @override + String get notesSourceLocation => 'Source location'; + + @override + String get notesRecoveryHelp => + 'Retained local notes and Local History only. Server trash and history are unavailable.'; + + @override + String get notesImport => 'Import notes'; + + @override + String get notesExport => 'Export notes'; + + @override + String get notesImportDistinct => 'Import as a distinct note'; + + @override + String get notesAvailableOffline => 'Available offline'; + + @override + String get notesIncomplete => 'Incomplete'; + + @override + String get notesSelected => 'Selected notes'; + + @override + String get notesNoHistory => + 'No retained revisions. History may be disabled, expired or cleared.'; + + @override + String get notesTextAvailable => 'Text available locally'; + + @override + String get notesLocalVersion => 'Local working version'; + + @override + String get notesAttachments => 'Attachments'; + + @override + String get notesSkipped => 'Skipped'; + + @override + String get notesFailed => 'Failed'; + + @override + String get notesCompleted => 'Completed'; + + @override + String get notesImportNew => 'Start a new import'; + + @override + String get notesImportResume => 'Resume interrupted import'; + + @override + String get notesImportAlreadyImported => 'Already imported in this operation'; + + @override + String get references => 'References'; + + @override + String get referenceIncoming => 'Incoming'; + + @override + String get referenceOutgoing => 'Outgoing'; + + @override + String get referenceChooseTarget => 'Choose link target'; + + @override + String get referenceWholeDocument => 'Whole document'; + + @override + String get referenceSourceLocation => 'Source location'; + + @override + String get referenceTargetLocation => 'Target location'; + + @override + String get referenceResolved => 'Resolved'; + + @override + String get referenceMissing => 'Missing'; + + @override + String get referenceAmbiguous => 'Ambiguous'; + + @override + String get referenceUnavailable => 'Unavailable in this context'; + + @override + String get referenceUnsupported => 'Unsupported'; + + @override + String get referenceNoPortableId => + 'This target has no verified portable note URL.'; } diff --git a/lib/l10n/generated/app_localizations_es.dart b/lib/l10n/generated/app_localizations_es.dart index 744490fa..d2ce2938 100644 --- a/lib/l10n/generated/app_localizations_es.dart +++ b/lib/l10n/generated/app_localizations_es.dart @@ -4837,4 +4837,282 @@ class AppLocalizationsEs extends AppLocalizations { @override String get nextcloudCreateSeparateWarning => 'La primera solicitud puede haber creado ya una nota en el servidor. Esto crea una nota aparte con su trabajo local conservado. No se eliminará ningún posible duplicado.'; + + @override + String get wsSemanticFormats => 'Formato semántico'; + + @override + String get wsUiControl => 'Control de interfaz'; + + @override + String get wsFilePath => 'Archivo / Ruta'; + + @override + String get wsUiPath => 'Ruta de interfaz'; + + @override + String get wsDefinitionList => 'Lista de definiciones'; + + @override + String get wsIncludeContent => 'Incluir contenido reutilizable'; + + @override + String get wsVariableReference => 'Referencia de variable'; + + @override + String get wsConvertList => 'Convertir lista en procedimiento'; + + @override + String get wsElementProperties => 'Propiedades del elemento'; + + @override + String get wsTopicProperties => 'Propiedades del tema'; + + @override + String get wsCollapsible => 'Contraíble'; + + @override + String get wsPublishedState => 'Estado inicial publicado'; + + @override + String get wsExpanded => 'Expandido'; + + @override + String get wsCollapsed => 'Contraído'; + + @override + String get wsCollapsedTitle => 'Título contraído'; + + @override + String get wsTerm => 'Término'; + + @override + String get wsListLayout => 'Diseño de la lista'; + + @override + String get wsSyncGroup => 'Grupo de sincronización'; + + @override + String get wsTabKey => 'Clave de pestaña'; + + @override + String get wsSwitcherLabel => 'Etiqueta del selector'; + + @override + String get wsNavigationElements => 'Elementos de navegación'; + + @override + String get wsNavigationDepth => 'Profundidad de navegación'; + + @override + String get wsAddItem => 'Añadir elemento'; + + @override + String get wsStep => 'Paso'; + + @override + String get wsPropertyInvalid => 'Valor de propiedad no válido'; + + @override + String get wsFilter => 'Filtro'; + + @override + String get wsIdentifier => 'ID del elemento'; + + @override + String get wsTldr => 'Resumen (TLDR)'; + + @override + String get wsFullLayout => 'Ancho completo'; + + @override + String get wsMediumLayout => 'Columnas iguales'; + + @override + String get wsNarrowLayout => 'Término estrecho'; + + @override + String get wsTitle => 'Título'; + + @override + String get wsSwitcherKey => 'Clave del selector'; + + @override + String get nextcloudServerSettings => 'Ajustes de Notes del servidor'; + + @override + String get nextcloudNotesPath => 'Ruta de la carpeta de notas'; + + @override + String get nextcloudFileSuffix => 'Extensión de las notas nuevas'; + + @override + String get nextcloudServerSettingsExplanation => + 'Estos ajustes de Notes afectan a otros clientes. Cambiar la ruta no mueve archivos; cambiar la extensión no renombra las notas existentes.'; + + @override + String get nextcloudSettingsSaved => 'Ajustes del servidor guardados'; + + @override + String get nextcloudSettingsNormalized => + 'Guardado con valores normalizados por el servidor'; + + @override + String get nextcloudPendingUpload => 'Carga de adjunto pendiente'; + + @override + String nextcloudServerCheck(String time) { + return 'Última comprobación del servidor: $time'; + } + + @override + String get nextcloudSettingsLoading => 'Cargando ajustes del servidor'; + + @override + String get nextcloudSettingsSaving => 'Guardando ajustes del servidor'; + + @override + String get nextcloudPreviousLocalEdit => 'Edición local anterior'; + + @override + String get nextcloudUsePreviousEdit => 'Usar edición anterior'; + + @override + String get quickOpen => 'Apertura rápida'; + + @override + String get notesAll => 'Todas las notas'; + + @override + String get notesUncategorized => 'Sin categoría'; + + @override + String get notesRecovery => 'Recuperación'; + + @override + String get notesRecentlyEdited => 'Editadas recientemente'; + + @override + String get notesOldestEdited => 'Ediciones más antiguas'; + + @override + String get notesTitleAscending => 'Título A–Z'; + + @override + String get notesTitleDescending => 'Título Z–A'; + + @override + String get notesMove => 'Mover a categoría'; + + @override + String get notesRemoveFavorite => 'Quitar favorito'; + + @override + String get notesOffline => 'Hacer disponible sin conexión'; + + @override + String get notesOfflineRemove => 'Quitar conservación sin conexión'; + + @override + String get notesIndexing => 'Indexando notas…'; + + @override + String get notesSearchHelp => + 'Los términos usan Y. Use \"frases entre comillas\", title: y category:.'; + + @override + String get notesSourceLocation => 'Ubicación en el código fuente'; + + @override + String get notesRecoveryHelp => + 'Solo notas locales conservadas e historial local. La papelera y el historial del servidor no están disponibles.'; + + @override + String get notesImport => 'Importar notas'; + + @override + String get notesExport => 'Exportar notas'; + + @override + String get notesImportDistinct => 'Importar como una nota distinta'; + + @override + String get notesAvailableOffline => 'Disponible sin conexión'; + + @override + String get notesIncomplete => 'Incompleto'; + + @override + String get notesSelected => 'Notas seleccionadas'; + + @override + String get notesNoHistory => + 'No hay revisiones conservadas. El historial puede estar desactivado, caducado o borrado.'; + + @override + String get notesTextAvailable => 'Texto disponible localmente'; + + @override + String get notesLocalVersion => 'Versión de trabajo local'; + + @override + String get notesAttachments => 'Adjuntos'; + + @override + String get notesSkipped => 'Omitido'; + + @override + String get notesFailed => 'Fallido'; + + @override + String get notesCompleted => 'Completado'; + + @override + String get notesImportNew => 'Iniciar una nueva importación'; + + @override + String get notesImportResume => 'Reanudar importación interrumpida'; + + @override + String get notesImportAlreadyImported => 'Ya importado en esta operación'; + + @override + String get references => 'Referencias'; + + @override + String get referenceIncoming => 'Entrantes'; + + @override + String get referenceOutgoing => 'Salientes'; + + @override + String get referenceChooseTarget => 'Elegir destino del enlace'; + + @override + String get referenceWholeDocument => 'Documento completo'; + + @override + String get referenceSourceLocation => 'Ubicación de origen'; + + @override + String get referenceTargetLocation => 'Ubicación de destino'; + + @override + String get referenceResolved => 'Resuelta'; + + @override + String get referenceMissing => 'No encontrada'; + + @override + String get referenceAmbiguous => 'Ambigua'; + + @override + String get referenceUnavailable => 'No disponible en este contexto'; + + @override + String get referenceUnsupported => 'No compatible'; + + @override + String get referenceNoPortableId => + 'Este destino no tiene una URL portátil de nota verificada.'; } diff --git a/lib/l10n/generated/app_localizations_et.dart b/lib/l10n/generated/app_localizations_et.dart index 1aa28afd..2167500b 100644 --- a/lib/l10n/generated/app_localizations_et.dart +++ b/lib/l10n/generated/app_localizations_et.dart @@ -4788,4 +4788,282 @@ class AppLocalizationsEt extends AppLocalizations { @override String get nextcloudCreateSeparateWarning => 'Esimene päring võis serveris juba märkuse luua. See loob eraldi märkuse koos säilitatud kohaliku tööga. Võimalikke duplikaate ei kustutata.'; + + @override + String get wsSemanticFormats => 'Semantiline vormindus'; + + @override + String get wsUiControl => 'Kasutajaliidese juhtelement'; + + @override + String get wsFilePath => 'Fail / Tee'; + + @override + String get wsUiPath => 'Kasutajaliidese tee'; + + @override + String get wsDefinitionList => 'Definitsiooniloend'; + + @override + String get wsIncludeContent => 'Lisa korduskasutatav sisu'; + + @override + String get wsVariableReference => 'Muutuja viide'; + + @override + String get wsConvertList => 'Teisenda loend protseduuriks'; + + @override + String get wsElementProperties => 'Elemendi omadused'; + + @override + String get wsTopicProperties => 'Teema omadused'; + + @override + String get wsCollapsible => 'Ahendatav'; + + @override + String get wsPublishedState => 'Avaldatud algolek'; + + @override + String get wsExpanded => 'Laiendatud'; + + @override + String get wsCollapsed => 'Ahendatud'; + + @override + String get wsCollapsedTitle => 'Ahendatud pealkiri'; + + @override + String get wsTerm => 'Termin'; + + @override + String get wsListLayout => 'Loendi paigutus'; + + @override + String get wsSyncGroup => 'Sünkroonimisrühm'; + + @override + String get wsTabKey => 'Vahelehe võti'; + + @override + String get wsSwitcherLabel => 'Lüliti silt'; + + @override + String get wsNavigationElements => 'Navigeerimise elemendid'; + + @override + String get wsNavigationDepth => 'Navigeerimise sügavus'; + + @override + String get wsAddItem => 'Lisa element'; + + @override + String get wsStep => 'Samm'; + + @override + String get wsPropertyInvalid => 'Vigane omaduse väärtus'; + + @override + String get wsFilter => 'Sisufilter'; + + @override + String get wsIdentifier => 'Elemendi ID'; + + @override + String get wsTldr => 'Kokkuvõte (TLDR)'; + + @override + String get wsFullLayout => 'Täislaius'; + + @override + String get wsMediumLayout => 'Võrdsed veerud'; + + @override + String get wsNarrowLayout => 'Kitsas terminiveerg'; + + @override + String get wsTitle => 'Pealkiri'; + + @override + String get wsSwitcherKey => 'Valikulüliti võti'; + + @override + String get nextcloudServerSettings => 'Serveri Notesi seaded'; + + @override + String get nextcloudNotesPath => 'Märkmete kausta tee'; + + @override + String get nextcloudFileSuffix => 'Uute märkmete faililaiend'; + + @override + String get nextcloudServerSettingsExplanation => + 'Need Notesi seaded mõjutavad teisi kliente. Tee muutmine ei teisalda faile; laiendi muutmine ei nimeta olemasolevaid märkmeid ümber.'; + + @override + String get nextcloudSettingsSaved => 'Serveri seaded salvestatud'; + + @override + String get nextcloudSettingsNormalized => + 'Salvestatud serveri normaliseeritud väärtustega'; + + @override + String get nextcloudPendingUpload => 'Manuse üleslaadimine ootel'; + + @override + String nextcloudServerCheck(String time) { + return 'Viimane serverikontroll: $time'; + } + + @override + String get nextcloudSettingsLoading => 'Serveri seadete laadimine'; + + @override + String get nextcloudSettingsSaving => 'Serveri seadete salvestamine'; + + @override + String get nextcloudPreviousLocalEdit => 'Eelmine kohalik muudatus'; + + @override + String get nextcloudUsePreviousEdit => 'Kasuta eelmist muudatust'; + + @override + String get quickOpen => 'Kiiravamine'; + + @override + String get notesAll => 'Kõik märkmed'; + + @override + String get notesUncategorized => 'Kategooriata'; + + @override + String get notesRecovery => 'Taastamine'; + + @override + String get notesRecentlyEdited => 'Hiljuti muudetud'; + + @override + String get notesOldestEdited => 'Vanim muutmine'; + + @override + String get notesTitleAscending => 'Pealkiri A–Z'; + + @override + String get notesTitleDescending => 'Pealkiri Z–A'; + + @override + String get notesMove => 'Teisalda kategooriasse'; + + @override + String get notesRemoveFavorite => 'Eemalda lemmik'; + + @override + String get notesOffline => 'Tee võrguta kättesaadavaks'; + + @override + String get notesOfflineRemove => 'Eemalda võrguta säilitamine'; + + @override + String get notesIndexing => 'Märkmete indekseerimine…'; + + @override + String get notesSearchHelp => + 'Mõisted ühendatakse JA abil. Kasuta \"tsiteeritud fraase\", title: ja category:.'; + + @override + String get notesSourceLocation => 'Lähtekoht'; + + @override + String get notesRecoveryHelp => + 'Ainult säilitatud kohalikud märkmed ja kohalik ajalugu. Serveri prügikast ja ajalugu pole saadaval.'; + + @override + String get notesImport => 'Impordi märkmed'; + + @override + String get notesExport => 'Ekspordi märkmed'; + + @override + String get notesImportDistinct => 'Impordi eraldi märkmena'; + + @override + String get notesAvailableOffline => 'Võrguta saadaval'; + + @override + String get notesIncomplete => 'Mittetäielik'; + + @override + String get notesSelected => 'Valitud märkmed'; + + @override + String get notesNoHistory => + 'Säilitatud versioone pole. Ajalugu võib olla keelatud, aegunud või kustutatud.'; + + @override + String get notesTextAvailable => 'Tekst on kohalikult saadaval'; + + @override + String get notesLocalVersion => 'Kohalik tööversioon'; + + @override + String get notesAttachments => 'Manused'; + + @override + String get notesSkipped => 'Vahele jäetud'; + + @override + String get notesFailed => 'Ebaõnnestus'; + + @override + String get notesCompleted => 'Lõpetatud'; + + @override + String get notesImportNew => 'Alusta uut importimist'; + + @override + String get notesImportResume => 'Jätka katkestatud importimist'; + + @override + String get notesImportAlreadyImported => 'Selles toimingus juba imporditud'; + + @override + String get references => 'Viited'; + + @override + String get referenceIncoming => 'Sissetulevad'; + + @override + String get referenceOutgoing => 'Väljaminevad'; + + @override + String get referenceChooseTarget => 'Vali lingi siht'; + + @override + String get referenceWholeDocument => 'Kogu dokument'; + + @override + String get referenceSourceLocation => 'Allika asukoht'; + + @override + String get referenceTargetLocation => 'Sihtkoha asukoht'; + + @override + String get referenceResolved => 'Lahendatud'; + + @override + String get referenceMissing => 'Puudub'; + + @override + String get referenceAmbiguous => 'Mitmetähenduslik'; + + @override + String get referenceUnavailable => 'Selles kontekstis pole saadaval'; + + @override + String get referenceUnsupported => 'Pole toetatud'; + + @override + String get referenceNoPortableId => + 'Sellel sihtkohal pole kinnitatud teisaldatavat märkme URL-i.'; } diff --git a/lib/l10n/generated/app_localizations_fa.dart b/lib/l10n/generated/app_localizations_fa.dart index 5255ea37..0187801f 100644 --- a/lib/l10n/generated/app_localizations_fa.dart +++ b/lib/l10n/generated/app_localizations_fa.dart @@ -4826,4 +4826,282 @@ class AppLocalizationsFa extends AppLocalizations { @override String get nextcloudCreateSeparateWarning => 'ممکن است درخواست نخست قبلاً یک یادداشت سرور ایجاد کرده باشد. این کار یک یادداشت جداگانه با کار محلی حفظ‌شدهٔ شما ایجاد می‌کند. هیچ یادداشت مشکوک به تکراری بودن حذف نمی‌شود.'; + + @override + String get wsSemanticFormats => 'قالب‌بندی معنایی'; + + @override + String get wsUiControl => 'کنترل رابط کاربری'; + + @override + String get wsFilePath => 'فایل / مسیر'; + + @override + String get wsUiPath => 'مسیر رابط کاربری'; + + @override + String get wsDefinitionList => 'فهرست تعریف‌ها'; + + @override + String get wsIncludeContent => 'درج محتوای قابل استفاده مجدد'; + + @override + String get wsVariableReference => 'ارجاع به متغیر'; + + @override + String get wsConvertList => 'تبدیل فهرست به رویه'; + + @override + String get wsElementProperties => 'ویژگی‌های عنصر'; + + @override + String get wsTopicProperties => 'ویژگی‌های موضوع'; + + @override + String get wsCollapsible => 'قابل جمع شدن'; + + @override + String get wsPublishedState => 'حالت اولیه هنگام انتشار'; + + @override + String get wsExpanded => 'باز'; + + @override + String get wsCollapsed => 'جمع شده'; + + @override + String get wsCollapsedTitle => 'عنوان در حالت جمع شده'; + + @override + String get wsTerm => 'اصطلاح'; + + @override + String get wsListLayout => 'چیدمان فهرست'; + + @override + String get wsSyncGroup => 'گروه همگام‌سازی'; + + @override + String get wsTabKey => 'کلید زبانه'; + + @override + String get wsSwitcherLabel => 'برچسب تغییر‌دهنده'; + + @override + String get wsNavigationElements => 'عناصر پیمایش'; + + @override + String get wsNavigationDepth => 'عمق پیمایش'; + + @override + String get wsAddItem => 'افزودن مورد'; + + @override + String get wsStep => 'گام'; + + @override + String get wsPropertyInvalid => 'مقدار ویژگی نامعتبر'; + + @override + String get wsFilter => 'فیلتر'; + + @override + String get wsIdentifier => 'شناسه عنصر'; + + @override + String get wsTldr => 'خلاصه (TLDR)'; + + @override + String get wsFullLayout => 'عرض کامل'; + + @override + String get wsMediumLayout => 'ستون‌های برابر'; + + @override + String get wsNarrowLayout => 'ستون باریک اصطلاح'; + + @override + String get wsTitle => 'عنوان'; + + @override + String get wsSwitcherKey => 'کلید انتخابگر'; + + @override + String get nextcloudServerSettings => 'تنظیمات Notes سرور'; + + @override + String get nextcloudNotesPath => 'مسیر پوشهٔ یادداشت‌ها'; + + @override + String get nextcloudFileSuffix => 'پسوند فایل یادداشت‌های جدید'; + + @override + String get nextcloudServerSettingsExplanation => + 'این تنظیمات Notes روی کارخواه‌های دیگر هم اثر می‌گذارد. تغییر مسیر فایل‌ها را جابه‌جا نمی‌کند؛ تغییر پسوند نام یادداشت‌های موجود را تغییر نمی‌دهد.'; + + @override + String get nextcloudSettingsSaved => 'تنظیمات سرور ذخیره شد'; + + @override + String get nextcloudSettingsNormalized => + 'با مقادیر نرمال‌شدهٔ سرور ذخیره شد'; + + @override + String get nextcloudPendingUpload => 'بارگذاری پیوست در انتظار است'; + + @override + String nextcloudServerCheck(String time) { + return 'آخرین بررسی سرور: $time'; + } + + @override + String get nextcloudSettingsLoading => 'در حال بارگیری تنظیمات سرور'; + + @override + String get nextcloudSettingsSaving => 'در حال ذخیره تنظیمات سرور'; + + @override + String get nextcloudPreviousLocalEdit => 'ویرایش محلی قبلی'; + + @override + String get nextcloudUsePreviousEdit => 'استفاده از ویرایش قبلی'; + + @override + String get quickOpen => 'باز کردن سریع'; + + @override + String get notesAll => 'همه یادداشت‌ها'; + + @override + String get notesUncategorized => 'بدون دسته'; + + @override + String get notesRecovery => 'بازیابی'; + + @override + String get notesRecentlyEdited => 'تازه ویرایش‌شده'; + + @override + String get notesOldestEdited => 'قدیمی‌ترین ویرایش'; + + @override + String get notesTitleAscending => 'عنوان صعودی'; + + @override + String get notesTitleDescending => 'عنوان نزولی'; + + @override + String get notesMove => 'انتقال به دسته'; + + @override + String get notesRemoveFavorite => 'حذف از علاقه‌مندی‌ها'; + + @override + String get notesOffline => 'در دسترس کردن آفلاین'; + + @override + String get notesOfflineRemove => 'حذف نگهداری آفلاین'; + + @override + String get notesIndexing => 'در حال نمایه‌سازی یادداشت‌ها…'; + + @override + String get notesSearchHelp => + 'واژه‌ها با AND ترکیب می‌شوند. از \"عبارت نقل‌شده\" و title: و category: استفاده کنید.'; + + @override + String get notesSourceLocation => 'موقعیت در منبع'; + + @override + String get notesRecoveryHelp => + 'فقط یادداشت‌های محلی نگهداری‌شده و تاریخچه محلی. زباله‌دان و تاریخچه سرور در دسترس نیست.'; + + @override + String get notesImport => 'وارد کردن یادداشت‌ها'; + + @override + String get notesExport => 'صادر کردن یادداشت‌ها'; + + @override + String get notesImportDistinct => 'وارد کردن به عنوان یادداشت جدا'; + + @override + String get notesAvailableOffline => 'در دسترس آفلاین'; + + @override + String get notesIncomplete => 'ناقص'; + + @override + String get notesSelected => 'یادداشت‌های انتخاب‌شده'; + + @override + String get notesNoHistory => + 'نسخه نگهداری‌شده‌ای نیست. تاریخچه ممکن است غیرفعال، منقضی یا پاک شده باشد.'; + + @override + String get notesTextAvailable => 'متن به صورت محلی در دسترس است'; + + @override + String get notesLocalVersion => 'نسخهٔ کاری محلی'; + + @override + String get notesAttachments => 'پیوست‌ها'; + + @override + String get notesSkipped => 'رد شد'; + + @override + String get notesFailed => 'ناموفق'; + + @override + String get notesCompleted => 'تکمیل شد'; + + @override + String get notesImportNew => 'شروع وارد کردن جدید'; + + @override + String get notesImportResume => 'ادامه وارد کردن متوقف‌شده'; + + @override + String get notesImportAlreadyImported => 'قبلاً در این عملیات وارد شده است'; + + @override + String get references => 'ارجاع‌ها'; + + @override + String get referenceIncoming => 'ورودی'; + + @override + String get referenceOutgoing => 'خروجی'; + + @override + String get referenceChooseTarget => 'انتخاب مقصد پیوند'; + + @override + String get referenceWholeDocument => 'کل سند'; + + @override + String get referenceSourceLocation => 'مکان منبع'; + + @override + String get referenceTargetLocation => 'مکان مقصد'; + + @override + String get referenceResolved => 'حل‌شده'; + + @override + String get referenceMissing => 'یافت نشد'; + + @override + String get referenceAmbiguous => 'مبهم'; + + @override + String get referenceUnavailable => 'در این زمینه در دسترس نیست'; + + @override + String get referenceUnsupported => 'پشتیبانی نمی‌شود'; + + @override + String get referenceNoPortableId => + 'این مقصد نشانی قابل‌انتقال و تأییدشده‌ای برای یادداشت ندارد.'; } diff --git a/lib/l10n/generated/app_localizations_fr.dart b/lib/l10n/generated/app_localizations_fr.dart index ad479e4f..0b426f6e 100644 --- a/lib/l10n/generated/app_localizations_fr.dart +++ b/lib/l10n/generated/app_localizations_fr.dart @@ -4839,4 +4839,283 @@ class AppLocalizationsFr extends AppLocalizations { @override String get nextcloudCreateSeparateWarning => 'La première requête a peut-être déjà créé une note sur le serveur. Ceci crée une note distincte avec votre travail local conservé. Aucun doublon supposé ne sera supprimé.'; + + @override + String get wsSemanticFormats => 'Mise en forme sémantique'; + + @override + String get wsUiControl => 'Contrôle d’interface'; + + @override + String get wsFilePath => 'Fichier / Chemin'; + + @override + String get wsUiPath => 'Chemin d’interface'; + + @override + String get wsDefinitionList => 'Liste de définitions'; + + @override + String get wsIncludeContent => 'Inclure du contenu réutilisable'; + + @override + String get wsVariableReference => 'Référence de variable'; + + @override + String get wsConvertList => 'Convertir la liste en procédure'; + + @override + String get wsElementProperties => 'Propriétés de l’élément'; + + @override + String get wsTopicProperties => 'Propriétés du sujet'; + + @override + String get wsCollapsible => 'Repliable'; + + @override + String get wsPublishedState => 'État initial publié'; + + @override + String get wsExpanded => 'Développé'; + + @override + String get wsCollapsed => 'Replié'; + + @override + String get wsCollapsedTitle => 'Titre replié'; + + @override + String get wsTerm => 'Terme'; + + @override + String get wsListLayout => 'Disposition de la liste'; + + @override + String get wsSyncGroup => 'Groupe de synchronisation'; + + @override + String get wsTabKey => 'Clé de l’onglet'; + + @override + String get wsSwitcherLabel => 'Libellé du sélecteur'; + + @override + String get wsNavigationElements => 'Éléments de navigation'; + + @override + String get wsNavigationDepth => 'Profondeur de navigation'; + + @override + String get wsAddItem => 'Ajouter un élément'; + + @override + String get wsStep => 'Étape'; + + @override + String get wsPropertyInvalid => 'Valeur de propriété non valide'; + + @override + String get wsFilter => 'Filtre'; + + @override + String get wsIdentifier => 'ID de l’élément'; + + @override + String get wsTldr => 'Résumé (TLDR)'; + + @override + String get wsFullLayout => 'Pleine largeur'; + + @override + String get wsMediumLayout => 'Colonnes égales'; + + @override + String get wsNarrowLayout => 'Terme étroit'; + + @override + String get wsTitle => 'Titre'; + + @override + String get wsSwitcherKey => 'Clé du sélecteur'; + + @override + String get nextcloudServerSettings => 'Paramètres Notes du serveur'; + + @override + String get nextcloudNotesPath => 'Chemin du dossier de notes'; + + @override + String get nextcloudFileSuffix => 'Extension des nouvelles notes'; + + @override + String get nextcloudServerSettingsExplanation => + 'Ces paramètres Notes du serveur affectent les autres clients. Changer le chemin ne déplace pas les fichiers ; changer l’extension ne renomme pas les notes existantes.'; + + @override + String get nextcloudSettingsSaved => 'Paramètres serveur enregistrés'; + + @override + String get nextcloudSettingsNormalized => + 'Enregistré avec les valeurs normalisées par le serveur'; + + @override + String get nextcloudPendingUpload => 'Envoi de pièce jointe en attente'; + + @override + String nextcloudServerCheck(String time) { + return 'Dernière vérification du serveur : $time'; + } + + @override + String get nextcloudSettingsLoading => 'Chargement des paramètres du serveur'; + + @override + String get nextcloudSettingsSaving => + 'Enregistrement des paramètres du serveur'; + + @override + String get nextcloudPreviousLocalEdit => 'Modification locale précédente'; + + @override + String get nextcloudUsePreviousEdit => 'Utiliser la modification précédente'; + + @override + String get quickOpen => 'Ouverture rapide'; + + @override + String get notesAll => 'Toutes les notes'; + + @override + String get notesUncategorized => 'Sans catégorie'; + + @override + String get notesRecovery => 'Récupération'; + + @override + String get notesRecentlyEdited => 'Modifiées récemment'; + + @override + String get notesOldestEdited => 'Modifiées les plus anciennes'; + + @override + String get notesTitleAscending => 'Titre A–Z'; + + @override + String get notesTitleDescending => 'Titre Z–A'; + + @override + String get notesMove => 'Déplacer vers une catégorie'; + + @override + String get notesRemoveFavorite => 'Retirer des favoris'; + + @override + String get notesOffline => 'Rendre disponible hors ligne'; + + @override + String get notesOfflineRemove => 'Retirer la conservation hors ligne'; + + @override + String get notesIndexing => 'Indexation des notes…'; + + @override + String get notesSearchHelp => + 'Les termes utilisent ET. Utilisez \"des expressions\", title: et category:.'; + + @override + String get notesSourceLocation => 'Emplacement source'; + + @override + String get notesRecoveryHelp => + 'Notes locales conservées et historique local uniquement. La corbeille et l’historique du serveur sont indisponibles.'; + + @override + String get notesImport => 'Importer des notes'; + + @override + String get notesExport => 'Exporter des notes'; + + @override + String get notesImportDistinct => 'Importer comme une note distincte'; + + @override + String get notesAvailableOffline => 'Disponible hors ligne'; + + @override + String get notesIncomplete => 'Incomplet'; + + @override + String get notesSelected => 'Notes sélectionnées'; + + @override + String get notesNoHistory => + 'Aucune révision conservée. L’historique peut être désactivé, expiré ou effacé.'; + + @override + String get notesTextAvailable => 'Texte disponible localement'; + + @override + String get notesLocalVersion => 'Version de travail locale'; + + @override + String get notesAttachments => 'Pièces jointes'; + + @override + String get notesSkipped => 'Ignoré'; + + @override + String get notesFailed => 'Échec'; + + @override + String get notesCompleted => 'Terminé'; + + @override + String get notesImportNew => 'Démarrer une nouvelle importation'; + + @override + String get notesImportResume => 'Reprendre l’importation interrompue'; + + @override + String get notesImportAlreadyImported => 'Déjà importé dans cette opération'; + + @override + String get references => 'Références'; + + @override + String get referenceIncoming => 'Entrantes'; + + @override + String get referenceOutgoing => 'Sortantes'; + + @override + String get referenceChooseTarget => 'Choisir la cible du lien'; + + @override + String get referenceWholeDocument => 'Document entier'; + + @override + String get referenceSourceLocation => 'Emplacement source'; + + @override + String get referenceTargetLocation => 'Emplacement cible'; + + @override + String get referenceResolved => 'Résolue'; + + @override + String get referenceMissing => 'Manquante'; + + @override + String get referenceAmbiguous => 'Ambiguë'; + + @override + String get referenceUnavailable => 'Indisponible dans ce contexte'; + + @override + String get referenceUnsupported => 'Non prise en charge'; + + @override + String get referenceNoPortableId => + 'Cette cible ne possède pas d’URL de note portable vérifiée.'; } diff --git a/lib/l10n/generated/app_localizations_hi.dart b/lib/l10n/generated/app_localizations_hi.dart index 864bd746..fb9134fd 100644 --- a/lib/l10n/generated/app_localizations_hi.dart +++ b/lib/l10n/generated/app_localizations_hi.dart @@ -4778,4 +4778,283 @@ class AppLocalizationsHi extends AppLocalizations { @override String get nextcloudCreateSeparateWarning => 'पहले अनुरोध ने सर्वर पर पहले ही नोट बना दिया हो सकता है। यह आपके सुरक्षित स्थानीय काम के साथ अलग नोट बनाता है। किसी संभावित प्रतिलिपि को हटाया नहीं जाएगा।'; + + @override + String get wsSemanticFormats => 'अर्थपूर्ण स्वरूपण'; + + @override + String get wsUiControl => 'यूआई नियंत्रण'; + + @override + String get wsFilePath => 'फ़ाइल / पथ'; + + @override + String get wsUiPath => 'यूआई पथ'; + + @override + String get wsDefinitionList => 'परिभाषा सूची'; + + @override + String get wsIncludeContent => 'पुनः उपयोग योग्य सामग्री शामिल करें'; + + @override + String get wsVariableReference => 'चर संदर्भ'; + + @override + String get wsConvertList => 'सूची को प्रक्रिया में बदलें'; + + @override + String get wsElementProperties => 'तत्व के गुण'; + + @override + String get wsTopicProperties => 'विषय के गुण'; + + @override + String get wsCollapsible => 'समेटने योग्य'; + + @override + String get wsPublishedState => 'प्रकाशित आरंभिक स्थिति'; + + @override + String get wsExpanded => 'विस्तृत'; + + @override + String get wsCollapsed => 'संक्षिप्त'; + + @override + String get wsCollapsedTitle => 'संक्षिप्त शीर्षक'; + + @override + String get wsTerm => 'शब्द'; + + @override + String get wsListLayout => 'सूची लेआउट'; + + @override + String get wsSyncGroup => 'सिंक समूह'; + + @override + String get wsTabKey => 'टैब कुंजी'; + + @override + String get wsSwitcherLabel => 'स्विचर लेबल'; + + @override + String get wsNavigationElements => 'नेविगेशन तत्व'; + + @override + String get wsNavigationDepth => 'नेविगेशन गहराई'; + + @override + String get wsAddItem => 'आइटम जोड़ें'; + + @override + String get wsStep => 'चरण'; + + @override + String get wsPropertyInvalid => 'अमान्य गुण मान'; + + @override + String get wsFilter => 'फ़िल्टर'; + + @override + String get wsIdentifier => 'तत्व आईडी'; + + @override + String get wsTldr => 'सारांश (TLDR)'; + + @override + String get wsFullLayout => 'पूरी चौड़ाई'; + + @override + String get wsMediumLayout => 'समान स्तंभ'; + + @override + String get wsNarrowLayout => 'संकीर्ण शब्द स्तंभ'; + + @override + String get wsTitle => 'शीर्षक'; + + @override + String get wsSwitcherKey => 'स्विचर कुंजी'; + + @override + String get nextcloudServerSettings => 'सर्वर Notes सेटिंग्स'; + + @override + String get nextcloudNotesPath => 'नोट फ़ोल्डर का पथ'; + + @override + String get nextcloudFileSuffix => 'नए नोट की फ़ाइल का प्रत्यय'; + + @override + String get nextcloudServerSettingsExplanation => + 'ये सर्वर Notes सेटिंग्स दूसरे क्लाइंट को भी प्रभावित करती हैं। पथ बदलने से फ़ाइलें स्थानांतरित नहीं होतीं; प्रत्यय बदलने से मौजूदा नोट का नाम नहीं बदलता।'; + + @override + String get nextcloudSettingsSaved => 'सर्वर सेटिंग्स सहेजी गईं'; + + @override + String get nextcloudSettingsNormalized => + 'सर्वर द्वारा सामान्यीकृत मानों के साथ सहेजा गया'; + + @override + String get nextcloudPendingUpload => 'अटैचमेंट अपलोड लंबित है'; + + @override + String nextcloudServerCheck(String time) { + return 'सर्वर की अंतिम जाँच: $time'; + } + + @override + String get nextcloudSettingsLoading => 'सर्वर सेटिंग लोड हो रही हैं'; + + @override + String get nextcloudSettingsSaving => 'सर्वर सेटिंग सहेजी जा रही हैं'; + + @override + String get nextcloudPreviousLocalEdit => 'पिछला स्थानीय संपादन'; + + @override + String get nextcloudUsePreviousEdit => 'पिछला संपादन इस्तेमाल करें'; + + @override + String get quickOpen => 'त्वरित खोलें'; + + @override + String get notesAll => 'सभी नोट्स'; + + @override + String get notesUncategorized => 'बिना श्रेणी'; + + @override + String get notesRecovery => 'पुनर्प्राप्ति'; + + @override + String get notesRecentlyEdited => 'हाल में संपादित'; + + @override + String get notesOldestEdited => 'सबसे पुराना संपादन'; + + @override + String get notesTitleAscending => 'शीर्षक आरोही'; + + @override + String get notesTitleDescending => 'शीर्षक अवरोही'; + + @override + String get notesMove => 'श्रेणी में ले जाएँ'; + + @override + String get notesRemoveFavorite => 'पसंदीदा हटाएँ'; + + @override + String get notesOffline => 'ऑफ़लाइन उपलब्ध करें'; + + @override + String get notesOfflineRemove => 'ऑफ़लाइन संरक्षण हटाएँ'; + + @override + String get notesIndexing => 'नोट्स की अनुक्रमणिका बन रही है…'; + + @override + String get notesSearchHelp => + 'शब्द AND से जुड़ते हैं। \"उद्धृत वाक्यांश\", title: और category: का उपयोग करें।'; + + @override + String get notesSourceLocation => 'स्रोत स्थान'; + + @override + String get notesRecoveryHelp => + 'केवल संरक्षित स्थानीय नोट्स और स्थानीय इतिहास। सर्वर का कचरा और इतिहास उपलब्ध नहीं है।'; + + @override + String get notesImport => 'नोट्स आयात करें'; + + @override + String get notesExport => 'नोट्स निर्यात करें'; + + @override + String get notesImportDistinct => 'अलग नोट के रूप में आयात करें'; + + @override + String get notesAvailableOffline => 'ऑफ़लाइन उपलब्ध'; + + @override + String get notesIncomplete => 'अपूर्ण'; + + @override + String get notesSelected => 'चुने गए नोट्स'; + + @override + String get notesNoHistory => + 'कोई संरक्षित संशोधन नहीं। इतिहास बंद, समाप्त या साफ़ किया गया हो सकता है।'; + + @override + String get notesTextAvailable => 'पाठ स्थानीय रूप से उपलब्ध है'; + + @override + String get notesLocalVersion => 'स्थानीय कार्य संस्करण'; + + @override + String get notesAttachments => 'अटैचमेंट'; + + @override + String get notesSkipped => 'छोड़ा गया'; + + @override + String get notesFailed => 'विफल'; + + @override + String get notesCompleted => 'पूर्ण'; + + @override + String get notesImportNew => 'नया आयात शुरू करें'; + + @override + String get notesImportResume => 'बाधित आयात फिर से शुरू करें'; + + @override + String get notesImportAlreadyImported => + 'इस प्रक्रिया में पहले ही आयात किया गया'; + + @override + String get references => 'संदर्भ'; + + @override + String get referenceIncoming => 'आने वाले'; + + @override + String get referenceOutgoing => 'जाने वाले'; + + @override + String get referenceChooseTarget => 'लिंक लक्ष्य चुनें'; + + @override + String get referenceWholeDocument => 'पूरा दस्तावेज़'; + + @override + String get referenceSourceLocation => 'स्रोत स्थान'; + + @override + String get referenceTargetLocation => 'लक्ष्य स्थान'; + + @override + String get referenceResolved => 'हल हुआ'; + + @override + String get referenceMissing => 'गुम'; + + @override + String get referenceAmbiguous => 'अस्पष्ट'; + + @override + String get referenceUnavailable => 'इस संदर्भ में अनुपलब्ध'; + + @override + String get referenceUnsupported => 'असमर्थित'; + + @override + String get referenceNoPortableId => + 'इस लक्ष्य का सत्यापित पोर्टेबल नोट URL नहीं है.'; } diff --git a/lib/l10n/generated/app_localizations_id.dart b/lib/l10n/generated/app_localizations_id.dart index 350e4af3..e3eaf016 100644 --- a/lib/l10n/generated/app_localizations_id.dart +++ b/lib/l10n/generated/app_localizations_id.dart @@ -4788,4 +4788,282 @@ class AppLocalizationsId extends AppLocalizations { @override String get nextcloudCreateSeparateWarning => 'Permintaan pertama mungkin sudah membuat catatan server. Ini membuat catatan terpisah dengan pekerjaan lokal yang dipertahankan. Tidak ada dugaan duplikat yang akan dihapus.'; + + @override + String get wsSemanticFormats => 'Pemformatan semantik'; + + @override + String get wsUiControl => 'Kontrol antarmuka'; + + @override + String get wsFilePath => 'Berkas / Jalur'; + + @override + String get wsUiPath => 'Jalur antarmuka'; + + @override + String get wsDefinitionList => 'Daftar definisi'; + + @override + String get wsIncludeContent => 'Sertakan konten yang dapat digunakan ulang'; + + @override + String get wsVariableReference => 'Referensi variabel'; + + @override + String get wsConvertList => 'Ubah daftar menjadi prosedur'; + + @override + String get wsElementProperties => 'Properti elemen'; + + @override + String get wsTopicProperties => 'Properti topik'; + + @override + String get wsCollapsible => 'Dapat diciutkan'; + + @override + String get wsPublishedState => 'Status awal publikasi'; + + @override + String get wsExpanded => 'Diperluas'; + + @override + String get wsCollapsed => 'Diciutkan'; + + @override + String get wsCollapsedTitle => 'Judul saat diciutkan'; + + @override + String get wsTerm => 'Istilah'; + + @override + String get wsListLayout => 'Tata letak daftar'; + + @override + String get wsSyncGroup => 'Grup sinkronisasi'; + + @override + String get wsTabKey => 'Kunci tab'; + + @override + String get wsSwitcherLabel => 'Label pengalih'; + + @override + String get wsNavigationElements => 'Elemen navigasi'; + + @override + String get wsNavigationDepth => 'Kedalaman navigasi'; + + @override + String get wsAddItem => 'Tambah item'; + + @override + String get wsStep => 'Langkah'; + + @override + String get wsPropertyInvalid => 'Nilai properti tidak valid'; + + @override + String get wsFilter => 'Filter konten'; + + @override + String get wsIdentifier => 'ID elemen'; + + @override + String get wsTldr => 'Ringkasan (TLDR)'; + + @override + String get wsFullLayout => 'Lebar penuh'; + + @override + String get wsMediumLayout => 'Kolom seimbang'; + + @override + String get wsNarrowLayout => 'Kolom istilah sempit'; + + @override + String get wsTitle => 'Judul'; + + @override + String get wsSwitcherKey => 'Kunci pemilih'; + + @override + String get nextcloudServerSettings => 'Pengaturan Notes server'; + + @override + String get nextcloudNotesPath => 'Jalur folder catatan'; + + @override + String get nextcloudFileSuffix => 'Ekstensi berkas catatan baru'; + + @override + String get nextcloudServerSettingsExplanation => + 'Pengaturan Notes server ini memengaruhi klien lain. Mengubah jalur tidak memindahkan berkas; mengubah ekstensi tidak mengganti nama catatan yang ada.'; + + @override + String get nextcloudSettingsSaved => 'Pengaturan server disimpan'; + + @override + String get nextcloudSettingsNormalized => + 'Disimpan dengan nilai yang dinormalisasi server'; + + @override + String get nextcloudPendingUpload => 'Unggahan lampiran tertunda'; + + @override + String nextcloudServerCheck(String time) { + return 'Pemeriksaan server terakhir: $time'; + } + + @override + String get nextcloudSettingsLoading => 'Memuat pengaturan server'; + + @override + String get nextcloudSettingsSaving => 'Menyimpan pengaturan server'; + + @override + String get nextcloudPreviousLocalEdit => 'Edit lokal sebelumnya'; + + @override + String get nextcloudUsePreviousEdit => 'Gunakan edit sebelumnya'; + + @override + String get quickOpen => 'Buka cepat'; + + @override + String get notesAll => 'Semua catatan'; + + @override + String get notesUncategorized => 'Tanpa kategori'; + + @override + String get notesRecovery => 'Pemulihan'; + + @override + String get notesRecentlyEdited => 'Baru diedit'; + + @override + String get notesOldestEdited => 'Edit terlama'; + + @override + String get notesTitleAscending => 'Judul A–Z'; + + @override + String get notesTitleDescending => 'Judul Z–A'; + + @override + String get notesMove => 'Pindah ke kategori'; + + @override + String get notesRemoveFavorite => 'Hapus favorit'; + + @override + String get notesOffline => 'Sediakan secara luring'; + + @override + String get notesOfflineRemove => 'Hapus penyimpanan luring'; + + @override + String get notesIndexing => 'Mengindeks catatan…'; + + @override + String get notesSearchHelp => + 'Istilah digabung dengan DAN. Gunakan \"frasa kutipan\", title: dan category:.'; + + @override + String get notesSourceLocation => 'Lokasi sumber'; + + @override + String get notesRecoveryHelp => + 'Hanya catatan lokal tersimpan dan riwayat lokal. Sampah dan riwayat server tidak tersedia.'; + + @override + String get notesImport => 'Impor catatan'; + + @override + String get notesExport => 'Ekspor catatan'; + + @override + String get notesImportDistinct => 'Impor sebagai catatan terpisah'; + + @override + String get notesAvailableOffline => 'Tersedia secara luring'; + + @override + String get notesIncomplete => 'Tidak lengkap'; + + @override + String get notesSelected => 'Catatan terpilih'; + + @override + String get notesNoHistory => + 'Tidak ada revisi tersimpan. Riwayat mungkin dinonaktifkan, kedaluwarsa atau dihapus.'; + + @override + String get notesTextAvailable => 'Teks tersedia secara lokal'; + + @override + String get notesLocalVersion => 'Versi kerja lokal'; + + @override + String get notesAttachments => 'Lampiran'; + + @override + String get notesSkipped => 'Dilewati'; + + @override + String get notesFailed => 'Gagal'; + + @override + String get notesCompleted => 'Selesai'; + + @override + String get notesImportNew => 'Mulai impor baru'; + + @override + String get notesImportResume => 'Lanjutkan impor yang terputus'; + + @override + String get notesImportAlreadyImported => 'Sudah diimpor dalam operasi ini'; + + @override + String get references => 'Referensi'; + + @override + String get referenceIncoming => 'Masuk'; + + @override + String get referenceOutgoing => 'Keluar'; + + @override + String get referenceChooseTarget => 'Pilih tujuan tautan'; + + @override + String get referenceWholeDocument => 'Seluruh dokumen'; + + @override + String get referenceSourceLocation => 'Lokasi sumber'; + + @override + String get referenceTargetLocation => 'Lokasi tujuan'; + + @override + String get referenceResolved => 'Terselesaikan'; + + @override + String get referenceMissing => 'Tidak ditemukan'; + + @override + String get referenceAmbiguous => 'Ambigu'; + + @override + String get referenceUnavailable => 'Tidak tersedia dalam konteks ini'; + + @override + String get referenceUnsupported => 'Tidak didukung'; + + @override + String get referenceNoPortableId => + 'Tujuan ini tidak memiliki URL catatan portabel yang terverifikasi.'; } diff --git a/lib/l10n/generated/app_localizations_it.dart b/lib/l10n/generated/app_localizations_it.dart index 9d934a04..d08bb917 100644 --- a/lib/l10n/generated/app_localizations_it.dart +++ b/lib/l10n/generated/app_localizations_it.dart @@ -4830,4 +4830,284 @@ class AppLocalizationsIt extends AppLocalizations { @override String get nextcloudCreateSeparateWarning => 'La prima richiesta potrebbe aver già creato una nota sul server. Questa azione crea una nota separata con il lavoro locale conservato. Nessun presunto duplicato verrà eliminato.'; + + @override + String get wsSemanticFormats => 'Formattazione semantica'; + + @override + String get wsUiControl => 'Controllo dell’interfaccia'; + + @override + String get wsFilePath => 'File / Percorso'; + + @override + String get wsUiPath => 'Percorso dell’interfaccia'; + + @override + String get wsDefinitionList => 'Elenco di definizioni'; + + @override + String get wsIncludeContent => 'Includi contenuto riutilizzabile'; + + @override + String get wsVariableReference => 'Riferimento a variabile'; + + @override + String get wsConvertList => 'Converti elenco in procedura'; + + @override + String get wsElementProperties => 'Proprietà dell’elemento'; + + @override + String get wsTopicProperties => 'Proprietà dell’argomento'; + + @override + String get wsCollapsible => 'Comprimibile'; + + @override + String get wsPublishedState => 'Stato iniziale pubblicato'; + + @override + String get wsExpanded => 'Espanso'; + + @override + String get wsCollapsed => 'Compresso'; + + @override + String get wsCollapsedTitle => 'Titolo compresso'; + + @override + String get wsTerm => 'Termine'; + + @override + String get wsListLayout => 'Disposizione dell’elenco'; + + @override + String get wsSyncGroup => 'Gruppo di sincronizzazione'; + + @override + String get wsTabKey => 'Chiave della scheda'; + + @override + String get wsSwitcherLabel => 'Etichetta del selettore'; + + @override + String get wsNavigationElements => 'Elementi di navigazione'; + + @override + String get wsNavigationDepth => 'Profondità di navigazione'; + + @override + String get wsAddItem => 'Aggiungi elemento'; + + @override + String get wsStep => 'Passaggio'; + + @override + String get wsPropertyInvalid => 'Valore della proprietà non valido'; + + @override + String get wsFilter => 'Filtro'; + + @override + String get wsIdentifier => 'ID elemento'; + + @override + String get wsTldr => 'Riepilogo (TLDR)'; + + @override + String get wsFullLayout => 'Larghezza piena'; + + @override + String get wsMediumLayout => 'Colonne uguali'; + + @override + String get wsNarrowLayout => 'Termine stretto'; + + @override + String get wsTitle => 'Titolo'; + + @override + String get wsSwitcherKey => 'Chiave del selettore'; + + @override + String get nextcloudServerSettings => 'Impostazioni Notes del server'; + + @override + String get nextcloudNotesPath => 'Percorso della cartella delle note'; + + @override + String get nextcloudFileSuffix => 'Estensione delle nuove note'; + + @override + String get nextcloudServerSettingsExplanation => + 'Queste impostazioni Notes influiscono sugli altri client. Cambiare il percorso non sposta i file; cambiare l’estensione non rinomina le note esistenti.'; + + @override + String get nextcloudSettingsSaved => 'Impostazioni del server salvate'; + + @override + String get nextcloudSettingsNormalized => + 'Salvato con i valori normalizzati dal server'; + + @override + String get nextcloudPendingUpload => 'Caricamento allegato in attesa'; + + @override + String nextcloudServerCheck(String time) { + return 'Ultimo controllo del server: $time'; + } + + @override + String get nextcloudSettingsLoading => + 'Caricamento delle impostazioni del server'; + + @override + String get nextcloudSettingsSaving => + 'Salvataggio delle impostazioni del server'; + + @override + String get nextcloudPreviousLocalEdit => 'Modifica locale precedente'; + + @override + String get nextcloudUsePreviousEdit => 'Usa la modifica precedente'; + + @override + String get quickOpen => 'Apertura rapida'; + + @override + String get notesAll => 'Tutte le note'; + + @override + String get notesUncategorized => 'Senza categoria'; + + @override + String get notesRecovery => 'Recupero'; + + @override + String get notesRecentlyEdited => 'Modificate di recente'; + + @override + String get notesOldestEdited => 'Modifiche più vecchie'; + + @override + String get notesTitleAscending => 'Titolo A–Z'; + + @override + String get notesTitleDescending => 'Titolo Z–A'; + + @override + String get notesMove => 'Sposta nella categoria'; + + @override + String get notesRemoveFavorite => 'Rimuovi preferito'; + + @override + String get notesOffline => 'Rendi disponibile offline'; + + @override + String get notesOfflineRemove => 'Rimuovi conservazione offline'; + + @override + String get notesIndexing => 'Indicizzazione delle note…'; + + @override + String get notesSearchHelp => + 'I termini usano E. Usa \"frasi tra virgolette\", title: e category:.'; + + @override + String get notesSourceLocation => 'Posizione nel sorgente'; + + @override + String get notesRecoveryHelp => + 'Solo note locali conservate e cronologia locale. Cestino e cronologia del server non disponibili.'; + + @override + String get notesImport => 'Importa note'; + + @override + String get notesExport => 'Esporta note'; + + @override + String get notesImportDistinct => 'Importa come nota distinta'; + + @override + String get notesAvailableOffline => 'Disponibile offline'; + + @override + String get notesIncomplete => 'Incompleto'; + + @override + String get notesSelected => 'Note selezionate'; + + @override + String get notesNoHistory => + 'Nessuna revisione conservata. La cronologia può essere disabilitata, scaduta o cancellata.'; + + @override + String get notesTextAvailable => 'Testo disponibile localmente'; + + @override + String get notesLocalVersion => 'Versione di lavoro locale'; + + @override + String get notesAttachments => 'Allegati'; + + @override + String get notesSkipped => 'Saltato'; + + @override + String get notesFailed => 'Non riuscito'; + + @override + String get notesCompleted => 'Completato'; + + @override + String get notesImportNew => 'Avvia una nuova importazione'; + + @override + String get notesImportResume => 'Riprendi importazione interrotta'; + + @override + String get notesImportAlreadyImported => 'Già importato in questa operazione'; + + @override + String get references => 'Riferimenti'; + + @override + String get referenceIncoming => 'In entrata'; + + @override + String get referenceOutgoing => 'In uscita'; + + @override + String get referenceChooseTarget => 'Scegli destinazione del collegamento'; + + @override + String get referenceWholeDocument => 'Intero documento'; + + @override + String get referenceSourceLocation => 'Posizione di origine'; + + @override + String get referenceTargetLocation => 'Posizione di destinazione'; + + @override + String get referenceResolved => 'Risolto'; + + @override + String get referenceMissing => 'Mancante'; + + @override + String get referenceAmbiguous => 'Ambiguo'; + + @override + String get referenceUnavailable => 'Non disponibile in questo contesto'; + + @override + String get referenceUnsupported => 'Non supportato'; + + @override + String get referenceNoPortableId => + 'Questa destinazione non ha un URL portabile della nota verificato.'; } diff --git a/lib/l10n/generated/app_localizations_ja.dart b/lib/l10n/generated/app_localizations_ja.dart index a4c4d779..ef9a944f 100644 --- a/lib/l10n/generated/app_localizations_ja.dart +++ b/lib/l10n/generated/app_localizations_ja.dart @@ -4655,4 +4655,278 @@ class AppLocalizationsJa extends AppLocalizations { @override String get nextcloudCreateSeparateWarning => '最初のリクエストですでにサーバーノートが作成された可能性があります。保持したローカル作業から別のノートを作成します。重複の疑いがあるノートは削除されません。'; + + @override + String get wsSemanticFormats => '意味に基づく書式'; + + @override + String get wsUiControl => 'UI コントロール'; + + @override + String get wsFilePath => 'ファイル / パス'; + + @override + String get wsUiPath => 'UI パス'; + + @override + String get wsDefinitionList => '定義リスト'; + + @override + String get wsIncludeContent => '再利用可能なコンテンツを含める'; + + @override + String get wsVariableReference => '変数参照'; + + @override + String get wsConvertList => 'リストを手順に変換'; + + @override + String get wsElementProperties => '要素のプロパティ'; + + @override + String get wsTopicProperties => 'トピックのプロパティ'; + + @override + String get wsCollapsible => '折りたたみ可能'; + + @override + String get wsPublishedState => '公開時の初期状態'; + + @override + String get wsExpanded => '展開'; + + @override + String get wsCollapsed => '折りたたみ'; + + @override + String get wsCollapsedTitle => '折りたたみ時のタイトル'; + + @override + String get wsTerm => '用語'; + + @override + String get wsListLayout => 'リストのレイアウト'; + + @override + String get wsSyncGroup => '同期グループ'; + + @override + String get wsTabKey => 'タブのキー'; + + @override + String get wsSwitcherLabel => '切り替えラベル'; + + @override + String get wsNavigationElements => 'ナビゲーション要素'; + + @override + String get wsNavigationDepth => 'ナビゲーションの深さ'; + + @override + String get wsAddItem => '項目を追加'; + + @override + String get wsStep => 'ステップ'; + + @override + String get wsPropertyInvalid => '無効なプロパティ値'; + + @override + String get wsFilter => 'フィルター'; + + @override + String get wsIdentifier => '要素 ID'; + + @override + String get wsTldr => '要約 (TLDR)'; + + @override + String get wsFullLayout => '全幅'; + + @override + String get wsMediumLayout => '均等な列'; + + @override + String get wsNarrowLayout => '狭い用語列'; + + @override + String get wsTitle => 'タイトル'; + + @override + String get wsSwitcherKey => '切り替えキー'; + + @override + String get nextcloudServerSettings => 'サーバーのNotes設定'; + + @override + String get nextcloudNotesPath => 'ノートフォルダーのパス'; + + @override + String get nextcloudFileSuffix => '新しいノートの拡張子'; + + @override + String get nextcloudServerSettingsExplanation => + 'このサーバーのNotes設定は他のクライアントにも影響します。パスを変更してもファイルは移動せず、拡張子を変更しても既存のノートの名前は変わりません。'; + + @override + String get nextcloudSettingsSaved => 'サーバー設定を保存しました'; + + @override + String get nextcloudSettingsNormalized => 'サーバーが正規化した値で保存しました'; + + @override + String get nextcloudPendingUpload => '添付ファイルのアップロード待ち'; + + @override + String nextcloudServerCheck(String time) { + return '前回のサーバー確認: $time'; + } + + @override + String get nextcloudSettingsLoading => 'サーバー設定を読み込み中'; + + @override + String get nextcloudSettingsSaving => 'サーバー設定を保存中'; + + @override + String get nextcloudPreviousLocalEdit => '以前のローカル編集'; + + @override + String get nextcloudUsePreviousEdit => '以前の編集を使用'; + + @override + String get quickOpen => 'クイックオープン'; + + @override + String get notesAll => 'すべてのノート'; + + @override + String get notesUncategorized => '未分類'; + + @override + String get notesRecovery => '復元'; + + @override + String get notesRecentlyEdited => '最近編集した順'; + + @override + String get notesOldestEdited => '古い編集順'; + + @override + String get notesTitleAscending => 'タイトル昇順'; + + @override + String get notesTitleDescending => 'タイトル降順'; + + @override + String get notesMove => 'カテゴリに移動'; + + @override + String get notesRemoveFavorite => 'お気に入りを解除'; + + @override + String get notesOffline => 'オフラインで利用可能にする'; + + @override + String get notesOfflineRemove => 'オフライン保持を解除'; + + @override + String get notesIndexing => 'ノートを索引作成中…'; + + @override + String get notesSearchHelp => + '語句は AND で結合します。\"引用句\"、title:、category: を使えます。'; + + @override + String get notesSourceLocation => 'ソース位置'; + + @override + String get notesRecoveryHelp => '保持されたローカルノートとローカル履歴のみ。サーバーのごみ箱や履歴は利用できません。'; + + @override + String get notesImport => 'ノートをインポート'; + + @override + String get notesExport => 'ノートをエクスポート'; + + @override + String get notesImportDistinct => '別のノートとしてインポート'; + + @override + String get notesAvailableOffline => 'オフラインで利用可能'; + + @override + String get notesIncomplete => '不完全'; + + @override + String get notesSelected => '選択したノート'; + + @override + String get notesNoHistory => '保持された版がありません。履歴は無効、期限切れ、または消去済みの場合があります。'; + + @override + String get notesTextAvailable => 'テキストはローカルで利用可能'; + + @override + String get notesLocalVersion => 'ローカルの作業版'; + + @override + String get notesAttachments => '添付ファイル'; + + @override + String get notesSkipped => 'スキップ'; + + @override + String get notesFailed => '失敗'; + + @override + String get notesCompleted => '完了'; + + @override + String get notesImportNew => '新しいインポートを開始'; + + @override + String get notesImportResume => '中断したインポートを再開'; + + @override + String get notesImportAlreadyImported => 'この操作ではインポート済み'; + + @override + String get references => '参照'; + + @override + String get referenceIncoming => '被参照'; + + @override + String get referenceOutgoing => '参照先'; + + @override + String get referenceChooseTarget => 'リンク先を選択'; + + @override + String get referenceWholeDocument => '文書全体'; + + @override + String get referenceSourceLocation => '参照元の位置'; + + @override + String get referenceTargetLocation => '参照先の位置'; + + @override + String get referenceResolved => '解決済み'; + + @override + String get referenceMissing => '見つかりません'; + + @override + String get referenceAmbiguous => '曖昧'; + + @override + String get referenceUnavailable => 'このコンテキストでは利用できません'; + + @override + String get referenceUnsupported => '未対応'; + + @override + String get referenceNoPortableId => 'この参照先には確認済みの移植可能なノートURLがありません。'; } diff --git a/lib/l10n/generated/app_localizations_ko.dart b/lib/l10n/generated/app_localizations_ko.dart index 0e9a24d3..50152c47 100644 --- a/lib/l10n/generated/app_localizations_ko.dart +++ b/lib/l10n/generated/app_localizations_ko.dart @@ -4650,4 +4650,279 @@ class AppLocalizationsKo extends AppLocalizations { @override String get nextcloudCreateSeparateWarning => '첫 요청으로 서버 노트가 이미 만들어졌을 수 있습니다. 보존된 로컬 작업으로 별도 노트를 만듭니다. 중복으로 의심되는 노트는 삭제하지 않습니다.'; + + @override + String get wsSemanticFormats => '의미 기반 서식'; + + @override + String get wsUiControl => 'UI 컨트롤'; + + @override + String get wsFilePath => '파일 / 경로'; + + @override + String get wsUiPath => 'UI 경로'; + + @override + String get wsDefinitionList => '정의 목록'; + + @override + String get wsIncludeContent => '재사용 가능한 콘텐츠 포함'; + + @override + String get wsVariableReference => '변수 참조'; + + @override + String get wsConvertList => '목록을 절차로 변환'; + + @override + String get wsElementProperties => '요소 속성'; + + @override + String get wsTopicProperties => '주제 속성'; + + @override + String get wsCollapsible => '접기 가능'; + + @override + String get wsPublishedState => '게시 시 초기 상태'; + + @override + String get wsExpanded => '펼침'; + + @override + String get wsCollapsed => '접힘'; + + @override + String get wsCollapsedTitle => '접힌 제목'; + + @override + String get wsTerm => '용어'; + + @override + String get wsListLayout => '목록 레이아웃'; + + @override + String get wsSyncGroup => '동기화 그룹'; + + @override + String get wsTabKey => '탭 키'; + + @override + String get wsSwitcherLabel => '전환 레이블'; + + @override + String get wsNavigationElements => '탐색 요소'; + + @override + String get wsNavigationDepth => '탐색 깊이'; + + @override + String get wsAddItem => '항목 추가'; + + @override + String get wsStep => '단계'; + + @override + String get wsPropertyInvalid => '잘못된 속성 값'; + + @override + String get wsFilter => '필터'; + + @override + String get wsIdentifier => '요소 ID'; + + @override + String get wsTldr => '요약 (TLDR)'; + + @override + String get wsFullLayout => '전체 너비'; + + @override + String get wsMediumLayout => '동일한 열'; + + @override + String get wsNarrowLayout => '좁은 용어 열'; + + @override + String get wsTitle => '제목'; + + @override + String get wsSwitcherKey => '전환 키'; + + @override + String get nextcloudServerSettings => '서버 Notes 설정'; + + @override + String get nextcloudNotesPath => '노트 폴더 경로'; + + @override + String get nextcloudFileSuffix => '새 노트의 파일 확장자'; + + @override + String get nextcloudServerSettingsExplanation => + '이 서버 Notes 설정은 다른 클라이언트에도 적용됩니다. 경로를 변경해도 파일이 이동하지 않으며 확장자를 변경해도 기존 노트의 이름이 바뀌지 않습니다.'; + + @override + String get nextcloudSettingsSaved => '서버 설정 저장됨'; + + @override + String get nextcloudSettingsNormalized => '서버에서 정규화한 값으로 저장됨'; + + @override + String get nextcloudPendingUpload => '첨부 파일 업로드 대기 중'; + + @override + String nextcloudServerCheck(String time) { + return '마지막 서버 확인: $time'; + } + + @override + String get nextcloudSettingsLoading => '서버 설정을 불러오는 중'; + + @override + String get nextcloudSettingsSaving => '서버 설정을 저장하는 중'; + + @override + String get nextcloudPreviousLocalEdit => '이전 로컬 편집'; + + @override + String get nextcloudUsePreviousEdit => '이전 편집 사용'; + + @override + String get quickOpen => '빠른 열기'; + + @override + String get notesAll => '모든 노트'; + + @override + String get notesUncategorized => '미분류'; + + @override + String get notesRecovery => '복구'; + + @override + String get notesRecentlyEdited => '최근 편집순'; + + @override + String get notesOldestEdited => '오래된 편집순'; + + @override + String get notesTitleAscending => '제목 오름차순'; + + @override + String get notesTitleDescending => '제목 내림차순'; + + @override + String get notesMove => '범주로 이동'; + + @override + String get notesRemoveFavorite => '즐겨찾기 해제'; + + @override + String get notesOffline => '오프라인 사용 설정'; + + @override + String get notesOfflineRemove => '오프라인 보존 해제'; + + @override + String get notesIndexing => '노트 색인 생성 중…'; + + @override + String get notesSearchHelp => + '검색어는 AND로 결합합니다. \"인용 구문\", title:, category:를 사용하세요.'; + + @override + String get notesSourceLocation => '소스 위치'; + + @override + String get notesRecoveryHelp => + '보존된 로컬 노트와 로컬 기록만 표시합니다. 서버 휴지통과 기록은 사용할 수 없습니다.'; + + @override + String get notesImport => '노트 가져오기'; + + @override + String get notesExport => '노트 내보내기'; + + @override + String get notesImportDistinct => '별도 노트로 가져오기'; + + @override + String get notesAvailableOffline => '오프라인 사용 가능'; + + @override + String get notesIncomplete => '불완전'; + + @override + String get notesSelected => '선택한 노트'; + + @override + String get notesNoHistory => '보존된 버전이 없습니다. 기록이 비활성화되었거나 만료 또는 삭제되었을 수 있습니다.'; + + @override + String get notesTextAvailable => '텍스트를 로컬에서 사용 가능'; + + @override + String get notesLocalVersion => '로컬 작업 버전'; + + @override + String get notesAttachments => '첨부 파일'; + + @override + String get notesSkipped => '건너뜀'; + + @override + String get notesFailed => '실패'; + + @override + String get notesCompleted => '완료'; + + @override + String get notesImportNew => '새 가져오기 시작'; + + @override + String get notesImportResume => '중단된 가져오기 재개'; + + @override + String get notesImportAlreadyImported => '이 작업에서 이미 가져옴'; + + @override + String get references => '참조'; + + @override + String get referenceIncoming => '들어오는 참조'; + + @override + String get referenceOutgoing => '나가는 참조'; + + @override + String get referenceChooseTarget => '링크 대상 선택'; + + @override + String get referenceWholeDocument => '전체 문서'; + + @override + String get referenceSourceLocation => '원본 위치'; + + @override + String get referenceTargetLocation => '대상 위치'; + + @override + String get referenceResolved => '해결됨'; + + @override + String get referenceMissing => '없음'; + + @override + String get referenceAmbiguous => '모호함'; + + @override + String get referenceUnavailable => '이 맥락에서 사용할 수 없음'; + + @override + String get referenceUnsupported => '지원되지 않음'; + + @override + String get referenceNoPortableId => '이 대상에는 확인된 이식 가능한 노트 URL이 없습니다.'; } diff --git a/lib/l10n/generated/app_localizations_nb.dart b/lib/l10n/generated/app_localizations_nb.dart index 01153870..98fa4cf5 100644 --- a/lib/l10n/generated/app_localizations_nb.dart +++ b/lib/l10n/generated/app_localizations_nb.dart @@ -4791,4 +4791,283 @@ class AppLocalizationsNb extends AppLocalizations { @override String get nextcloudCreateSeparateWarning => 'Den første forespørselen kan allerede ha opprettet et servernotat. Dette oppretter et separat notat med ditt beholdte lokale arbeid. Ingen antatte duplikater slettes.'; + + @override + String get wsSemanticFormats => 'Semantisk formatering'; + + @override + String get wsUiControl => 'UI-kontroll'; + + @override + String get wsFilePath => 'Fil / Sti'; + + @override + String get wsUiPath => 'UI-sti'; + + @override + String get wsDefinitionList => 'Definisjonsliste'; + + @override + String get wsIncludeContent => 'Inkluder gjenbrukbart innhold'; + + @override + String get wsVariableReference => 'Variabelreferanse'; + + @override + String get wsConvertList => 'Konverter liste til prosedyre'; + + @override + String get wsElementProperties => 'Elementegenskaper'; + + @override + String get wsTopicProperties => 'Emneegenskaper'; + + @override + String get wsCollapsible => 'Sammenleggbar'; + + @override + String get wsPublishedState => 'Publisert starttilstand'; + + @override + String get wsExpanded => 'Utvidet'; + + @override + String get wsCollapsed => 'Sammenlagt'; + + @override + String get wsCollapsedTitle => 'Sammenlagt tittel'; + + @override + String get wsTerm => 'Begrep'; + + @override + String get wsListLayout => 'Listeoppsett'; + + @override + String get wsSyncGroup => 'Synkroniseringsgruppe'; + + @override + String get wsTabKey => 'Fanenøkkel'; + + @override + String get wsSwitcherLabel => 'Bytteetikett'; + + @override + String get wsNavigationElements => 'Navigasjonselementer'; + + @override + String get wsNavigationDepth => 'Navigasjonsdybde'; + + @override + String get wsAddItem => 'Legg til element'; + + @override + String get wsStep => 'Trinn'; + + @override + String get wsPropertyInvalid => 'Ugyldig egenskapsverdi'; + + @override + String get wsFilter => 'Innholdsfilter'; + + @override + String get wsIdentifier => 'Element-ID'; + + @override + String get wsTldr => 'Sammendrag (TLDR)'; + + @override + String get wsFullLayout => 'Full bredde'; + + @override + String get wsMediumLayout => 'Like kolonner'; + + @override + String get wsNarrowLayout => 'Smal termkolonne'; + + @override + String get wsTitle => 'Tittel'; + + @override + String get wsSwitcherKey => 'Byttenøkkel'; + + @override + String get nextcloudServerSettings => 'Notes-innstillinger på serveren'; + + @override + String get nextcloudNotesPath => 'Sti til notatmappen'; + + @override + String get nextcloudFileSuffix => 'Filendelse for nye notater'; + + @override + String get nextcloudServerSettingsExplanation => + 'Disse Notes-innstillingene påvirker andre klienter. Endring av stien flytter ikke filer; endring av filendelsen gir ikke eksisterende notater nye navn.'; + + @override + String get nextcloudSettingsSaved => 'Serverinnstillinger lagret'; + + @override + String get nextcloudSettingsNormalized => + 'Lagret med verdier normalisert av serveren'; + + @override + String get nextcloudPendingUpload => 'Vedlegg venter på opplasting'; + + @override + String nextcloudServerCheck(String time) { + return 'Siste serverkontroll: $time'; + } + + @override + String get nextcloudSettingsLoading => 'Laster serverinnstillinger'; + + @override + String get nextcloudSettingsSaving => 'Lagrer serverinnstillinger'; + + @override + String get nextcloudPreviousLocalEdit => 'Forrige lokale endring'; + + @override + String get nextcloudUsePreviousEdit => 'Bruk forrige endring'; + + @override + String get quickOpen => 'Hurtigåpning'; + + @override + String get notesAll => 'Alle notater'; + + @override + String get notesUncategorized => 'Uten kategori'; + + @override + String get notesRecovery => 'Gjenoppretting'; + + @override + String get notesRecentlyEdited => 'Nylig redigert'; + + @override + String get notesOldestEdited => 'Eldste redigering'; + + @override + String get notesTitleAscending => 'Tittel A–Z'; + + @override + String get notesTitleDescending => 'Tittel Z–A'; + + @override + String get notesMove => 'Flytt til kategori'; + + @override + String get notesRemoveFavorite => 'Fjern favoritt'; + + @override + String get notesOffline => 'Gjør tilgjengelig frakoblet'; + + @override + String get notesOfflineRemove => 'Fjern frakoblet oppbevaring'; + + @override + String get notesIndexing => 'Indekserer notater…'; + + @override + String get notesSearchHelp => + 'Ord bruker OG. Bruk \"siterte fraser\", title: og category:.'; + + @override + String get notesSourceLocation => 'Kildeplassering'; + + @override + String get notesRecoveryHelp => + 'Kun beholdte lokale notater og lokal historikk. Serverens papirkurv og historikk er utilgjengelige.'; + + @override + String get notesImport => 'Importer notater'; + + @override + String get notesExport => 'Eksporter notater'; + + @override + String get notesImportDistinct => 'Importer som eget notat'; + + @override + String get notesAvailableOffline => 'Tilgjengelig frakoblet'; + + @override + String get notesIncomplete => 'Ufullstendig'; + + @override + String get notesSelected => 'Valgte notater'; + + @override + String get notesNoHistory => + 'Ingen beholdte revisjoner. Historikken kan være deaktivert, utløpt eller slettet.'; + + @override + String get notesTextAvailable => 'Tekst tilgjengelig lokalt'; + + @override + String get notesLocalVersion => 'Lokal arbeidsversjon'; + + @override + String get notesAttachments => 'Vedlegg'; + + @override + String get notesSkipped => 'Hoppet over'; + + @override + String get notesFailed => 'Mislyktes'; + + @override + String get notesCompleted => 'Fullført'; + + @override + String get notesImportNew => 'Start en ny import'; + + @override + String get notesImportResume => 'Fortsett avbrutt import'; + + @override + String get notesImportAlreadyImported => + 'Allerede importert i denne operasjonen'; + + @override + String get references => 'Referanser'; + + @override + String get referenceIncoming => 'Innkommende'; + + @override + String get referenceOutgoing => 'Utgående'; + + @override + String get referenceChooseTarget => 'Velg lenkemål'; + + @override + String get referenceWholeDocument => 'Hele dokumentet'; + + @override + String get referenceSourceLocation => 'Kildeplassering'; + + @override + String get referenceTargetLocation => 'Målplassering'; + + @override + String get referenceResolved => 'Løst'; + + @override + String get referenceMissing => 'Mangler'; + + @override + String get referenceAmbiguous => 'Tvetydig'; + + @override + String get referenceUnavailable => 'Utilgjengelig i denne konteksten'; + + @override + String get referenceUnsupported => 'Ikke støttet'; + + @override + String get referenceNoPortableId => + 'Dette målet har ingen bekreftet portabel notatadresse.'; } diff --git a/lib/l10n/generated/app_localizations_nl.dart b/lib/l10n/generated/app_localizations_nl.dart index 46021aec..821214c6 100644 --- a/lib/l10n/generated/app_localizations_nl.dart +++ b/lib/l10n/generated/app_localizations_nl.dart @@ -4836,4 +4836,282 @@ class AppLocalizationsNl extends AppLocalizations { @override String get nextcloudCreateSeparateWarning => 'Het eerste verzoek heeft mogelijk al een servernotitie gemaakt. Dit maakt een afzonderlijke notitie met uw behouden lokale werk. Vermoedelijke duplicaten worden niet verwijderd.'; + + @override + String get wsSemanticFormats => 'Semantische opmaak'; + + @override + String get wsUiControl => 'UI-besturingselement'; + + @override + String get wsFilePath => 'Bestand / Pad'; + + @override + String get wsUiPath => 'UI-pad'; + + @override + String get wsDefinitionList => 'Definitielijst'; + + @override + String get wsIncludeContent => 'Herbruikbare inhoud opnemen'; + + @override + String get wsVariableReference => 'Variabelereferentie'; + + @override + String get wsConvertList => 'Lijst omzetten naar procedure'; + + @override + String get wsElementProperties => 'Elementeigenschappen'; + + @override + String get wsTopicProperties => 'Onderwerpeigenschappen'; + + @override + String get wsCollapsible => 'Inklapbaar'; + + @override + String get wsPublishedState => 'Gepubliceerde beginstatus'; + + @override + String get wsExpanded => 'Uitgeklapt'; + + @override + String get wsCollapsed => 'Ingeklapt'; + + @override + String get wsCollapsedTitle => 'Ingeklapte titel'; + + @override + String get wsTerm => 'Begrip'; + + @override + String get wsListLayout => 'Lijstindeling'; + + @override + String get wsSyncGroup => 'Synchronisatiegroep'; + + @override + String get wsTabKey => 'Tabsleutel'; + + @override + String get wsSwitcherLabel => 'Schakelaarlabel'; + + @override + String get wsNavigationElements => 'Navigatie-elementen'; + + @override + String get wsNavigationDepth => 'Navigatiediepte'; + + @override + String get wsAddItem => 'Item toevoegen'; + + @override + String get wsStep => 'Stap'; + + @override + String get wsPropertyInvalid => 'Ongeldige eigenschapswaarde'; + + @override + String get wsFilter => 'Inhoudsfilter'; + + @override + String get wsIdentifier => 'Element-ID'; + + @override + String get wsTldr => 'Samenvatting (TLDR)'; + + @override + String get wsFullLayout => 'Volledige breedte'; + + @override + String get wsMediumLayout => 'Gelijke kolommen'; + + @override + String get wsNarrowLayout => 'Smalle begripskolom'; + + @override + String get wsTitle => 'Titel'; + + @override + String get wsSwitcherKey => 'Keuzeschakelaar'; + + @override + String get nextcloudServerSettings => 'Notes-instellingen op de server'; + + @override + String get nextcloudNotesPath => 'Pad van de notitiemap'; + + @override + String get nextcloudFileSuffix => 'Bestandsextensie voor nieuwe notities'; + + @override + String get nextcloudServerSettingsExplanation => + 'Deze Notes-instellingen gelden ook voor andere clients. Een ander pad verplaatst geen bestanden; een andere extensie hernoemt bestaande notities niet.'; + + @override + String get nextcloudSettingsSaved => 'Serverinstellingen opgeslagen'; + + @override + String get nextcloudSettingsNormalized => + 'Opgeslagen met door de server genormaliseerde waarden'; + + @override + String get nextcloudPendingUpload => 'Bijlage wacht op upload'; + + @override + String nextcloudServerCheck(String time) { + return 'Laatste servercontrole: $time'; + } + + @override + String get nextcloudSettingsLoading => 'Serverinstellingen laden'; + + @override + String get nextcloudSettingsSaving => 'Serverinstellingen opslaan'; + + @override + String get nextcloudPreviousLocalEdit => 'Vorige lokale wijziging'; + + @override + String get nextcloudUsePreviousEdit => 'Vorige wijziging gebruiken'; + + @override + String get quickOpen => 'Snel openen'; + + @override + String get notesAll => 'Alle notities'; + + @override + String get notesUncategorized => 'Zonder categorie'; + + @override + String get notesRecovery => 'Herstel'; + + @override + String get notesRecentlyEdited => 'Recent bewerkt'; + + @override + String get notesOldestEdited => 'Oudste bewerking'; + + @override + String get notesTitleAscending => 'Titel A–Z'; + + @override + String get notesTitleDescending => 'Titel Z–A'; + + @override + String get notesMove => 'Naar categorie verplaatsen'; + + @override + String get notesRemoveFavorite => 'Favoriet verwijderen'; + + @override + String get notesOffline => 'Offline beschikbaar maken'; + + @override + String get notesOfflineRemove => 'Offlinebewaring verwijderen'; + + @override + String get notesIndexing => 'Notities indexeren…'; + + @override + String get notesSearchHelp => + 'Termen gebruiken EN. Gebruik \"geciteerde zinnen\", title: en category:.'; + + @override + String get notesSourceLocation => 'Bronlocatie'; + + @override + String get notesRecoveryHelp => + 'Alleen bewaarde lokale notities en lokale geschiedenis. Prullenbak en geschiedenis van de server zijn niet beschikbaar.'; + + @override + String get notesImport => 'Notities importeren'; + + @override + String get notesExport => 'Notities exporteren'; + + @override + String get notesImportDistinct => 'Als afzonderlijke notitie importeren'; + + @override + String get notesAvailableOffline => 'Offline beschikbaar'; + + @override + String get notesIncomplete => 'Onvolledig'; + + @override + String get notesSelected => 'Geselecteerde notities'; + + @override + String get notesNoHistory => + 'Geen bewaarde revisies. Geschiedenis kan uitgeschakeld, verlopen of gewist zijn.'; + + @override + String get notesTextAvailable => 'Tekst lokaal beschikbaar'; + + @override + String get notesLocalVersion => 'Lokale werkversie'; + + @override + String get notesAttachments => 'Bijlagen'; + + @override + String get notesSkipped => 'Overgeslagen'; + + @override + String get notesFailed => 'Mislukt'; + + @override + String get notesCompleted => 'Voltooid'; + + @override + String get notesImportNew => 'Nieuwe import starten'; + + @override + String get notesImportResume => 'Onderbroken import hervatten'; + + @override + String get notesImportAlreadyImported => 'Al geïmporteerd in deze bewerking'; + + @override + String get references => 'Verwijzingen'; + + @override + String get referenceIncoming => 'Inkomend'; + + @override + String get referenceOutgoing => 'Uitgaand'; + + @override + String get referenceChooseTarget => 'Linkdoel kiezen'; + + @override + String get referenceWholeDocument => 'Heel document'; + + @override + String get referenceSourceLocation => 'Bronlocatie'; + + @override + String get referenceTargetLocation => 'Doellocatie'; + + @override + String get referenceResolved => 'Opgelost'; + + @override + String get referenceMissing => 'Ontbreekt'; + + @override + String get referenceAmbiguous => 'Dubbelzinnig'; + + @override + String get referenceUnavailable => 'Niet beschikbaar in deze context'; + + @override + String get referenceUnsupported => 'Niet ondersteund'; + + @override + String get referenceNoPortableId => + 'Dit doel heeft geen geverifieerde overdraagbare notitie-URL.'; } diff --git a/lib/l10n/generated/app_localizations_pl.dart b/lib/l10n/generated/app_localizations_pl.dart index 04b550f9..e7bd6bbd 100644 --- a/lib/l10n/generated/app_localizations_pl.dart +++ b/lib/l10n/generated/app_localizations_pl.dart @@ -4844,4 +4844,282 @@ class AppLocalizationsPl extends AppLocalizations { @override String get nextcloudCreateSeparateWarning => 'Pierwsze żądanie mogło już utworzyć notatkę na serwerze. Ta czynność tworzy osobną notatkę z zachowaną pracą lokalną. Żadne podejrzane duplikaty nie zostaną usunięte.'; + + @override + String get wsSemanticFormats => 'Formatowanie semantyczne'; + + @override + String get wsUiControl => 'Element interfejsu'; + + @override + String get wsFilePath => 'Plik / Ścieżka'; + + @override + String get wsUiPath => 'Ścieżka interfejsu'; + + @override + String get wsDefinitionList => 'Lista definicji'; + + @override + String get wsIncludeContent => 'Dołącz treść wielokrotnego użytku'; + + @override + String get wsVariableReference => 'Odwołanie do zmiennej'; + + @override + String get wsConvertList => 'Przekształć listę w procedurę'; + + @override + String get wsElementProperties => 'Właściwości elementu'; + + @override + String get wsTopicProperties => 'Właściwości tematu'; + + @override + String get wsCollapsible => 'Możliwość zwijania'; + + @override + String get wsPublishedState => 'Opublikowany stan początkowy'; + + @override + String get wsExpanded => 'Rozwinięty'; + + @override + String get wsCollapsed => 'Zwinięty'; + + @override + String get wsCollapsedTitle => 'Zwinięty tytuł'; + + @override + String get wsTerm => 'Termin'; + + @override + String get wsListLayout => 'Układ listy'; + + @override + String get wsSyncGroup => 'Grupa synchronizacji'; + + @override + String get wsTabKey => 'Klucz karty'; + + @override + String get wsSwitcherLabel => 'Etykieta przełącznika'; + + @override + String get wsNavigationElements => 'Elementy nawigacji'; + + @override + String get wsNavigationDepth => 'Głębokość nawigacji'; + + @override + String get wsAddItem => 'Dodaj element'; + + @override + String get wsStep => 'Krok'; + + @override + String get wsPropertyInvalid => 'Nieprawidłowa wartość właściwości'; + + @override + String get wsFilter => 'Filtr'; + + @override + String get wsIdentifier => 'Identyfikator elementu'; + + @override + String get wsTldr => 'Podsumowanie (TLDR)'; + + @override + String get wsFullLayout => 'Pełna szerokość'; + + @override + String get wsMediumLayout => 'Równe kolumny'; + + @override + String get wsNarrowLayout => 'Wąska kolumna terminu'; + + @override + String get wsTitle => 'Tytuł'; + + @override + String get wsSwitcherKey => 'Klucz przełącznika'; + + @override + String get nextcloudServerSettings => 'Ustawienia Notes na serwerze'; + + @override + String get nextcloudNotesPath => 'Ścieżka folderu notatek'; + + @override + String get nextcloudFileSuffix => 'Rozszerzenie nowych notatek'; + + @override + String get nextcloudServerSettingsExplanation => + 'Te ustawienia Notes dotyczą innych klientów. Zmiana ścieżki nie przenosi plików; zmiana rozszerzenia nie zmienia nazw istniejących notatek.'; + + @override + String get nextcloudSettingsSaved => 'Zapisano ustawienia serwera'; + + @override + String get nextcloudSettingsNormalized => + 'Zapisano wartości znormalizowane przez serwer'; + + @override + String get nextcloudPendingUpload => 'Załącznik oczekuje na wysłanie'; + + @override + String nextcloudServerCheck(String time) { + return 'Ostatnie sprawdzenie serwera: $time'; + } + + @override + String get nextcloudSettingsLoading => 'Wczytywanie ustawień serwera'; + + @override + String get nextcloudSettingsSaving => 'Zapisywanie ustawień serwera'; + + @override + String get nextcloudPreviousLocalEdit => 'Poprzednia lokalna zmiana'; + + @override + String get nextcloudUsePreviousEdit => 'Użyj poprzedniej zmiany'; + + @override + String get quickOpen => 'Szybkie otwieranie'; + + @override + String get notesAll => 'Wszystkie notatki'; + + @override + String get notesUncategorized => 'Bez kategorii'; + + @override + String get notesRecovery => 'Odzyskiwanie'; + + @override + String get notesRecentlyEdited => 'Ostatnio edytowane'; + + @override + String get notesOldestEdited => 'Najstarsza edycja'; + + @override + String get notesTitleAscending => 'Tytuł A–Z'; + + @override + String get notesTitleDescending => 'Tytuł Z–A'; + + @override + String get notesMove => 'Przenieś do kategorii'; + + @override + String get notesRemoveFavorite => 'Usuń z ulubionych'; + + @override + String get notesOffline => 'Udostępnij offline'; + + @override + String get notesOfflineRemove => 'Usuń przechowywanie offline'; + + @override + String get notesIndexing => 'Indeksowanie notatek…'; + + @override + String get notesSearchHelp => + 'Terminy używają ORAZ. Użyj \"cytowanych fraz\", title: i category:.'; + + @override + String get notesSourceLocation => 'Pozycja w źródle'; + + @override + String get notesRecoveryHelp => + 'Tylko zachowane notatki lokalne i historia lokalna. Kosz i historia serwera są niedostępne.'; + + @override + String get notesImport => 'Importuj notatki'; + + @override + String get notesExport => 'Eksportuj notatki'; + + @override + String get notesImportDistinct => 'Importuj jako osobną notatkę'; + + @override + String get notesAvailableOffline => 'Dostępne offline'; + + @override + String get notesIncomplete => 'Niekompletne'; + + @override + String get notesSelected => 'Wybrane notatki'; + + @override + String get notesNoHistory => + 'Brak zachowanych wersji. Historia może być wyłączona, wygasła lub wyczyszczona.'; + + @override + String get notesTextAvailable => 'Tekst dostępny lokalnie'; + + @override + String get notesLocalVersion => 'Lokalna wersja robocza'; + + @override + String get notesAttachments => 'Załączniki'; + + @override + String get notesSkipped => 'Pominięto'; + + @override + String get notesFailed => 'Niepowodzenie'; + + @override + String get notesCompleted => 'Ukończono'; + + @override + String get notesImportNew => 'Rozpocznij nowy import'; + + @override + String get notesImportResume => 'Wznów przerwany import'; + + @override + String get notesImportAlreadyImported => 'Już zaimportowano w tej operacji'; + + @override + String get references => 'Odwołania'; + + @override + String get referenceIncoming => 'Przychodzące'; + + @override + String get referenceOutgoing => 'Wychodzące'; + + @override + String get referenceChooseTarget => 'Wybierz cel linku'; + + @override + String get referenceWholeDocument => 'Cały dokument'; + + @override + String get referenceSourceLocation => 'Lokalizacja źródła'; + + @override + String get referenceTargetLocation => 'Lokalizacja celu'; + + @override + String get referenceResolved => 'Rozwiązane'; + + @override + String get referenceMissing => 'Brak'; + + @override + String get referenceAmbiguous => 'Niejednoznaczne'; + + @override + String get referenceUnavailable => 'Niedostępne w tym kontekście'; + + @override + String get referenceUnsupported => 'Nieobsługiwane'; + + @override + String get referenceNoPortableId => + 'Ten cel nie ma zweryfikowanego przenośnego adresu URL notatki.'; } diff --git a/lib/l10n/generated/app_localizations_pt.dart b/lib/l10n/generated/app_localizations_pt.dart index 96acc585..31afb99b 100644 --- a/lib/l10n/generated/app_localizations_pt.dart +++ b/lib/l10n/generated/app_localizations_pt.dart @@ -4851,6 +4851,284 @@ class AppLocalizationsPt extends AppLocalizations { @override String get nextcloudCreateSeparateWarning => 'O primeiro pedido pode já ter criado uma nota no servidor. Isto cria uma nota separada com o trabalho local preservado. Nenhum possível duplicado será eliminado.'; + + @override + String get wsSemanticFormats => 'Formatação semântica'; + + @override + String get wsUiControl => 'Controlo da interface'; + + @override + String get wsFilePath => 'Ficheiro / Caminho'; + + @override + String get wsUiPath => 'Caminho da interface'; + + @override + String get wsDefinitionList => 'Lista de definições'; + + @override + String get wsIncludeContent => 'Incluir conteúdo reutilizável'; + + @override + String get wsVariableReference => 'Referência de variável'; + + @override + String get wsConvertList => 'Converter lista em procedimento'; + + @override + String get wsElementProperties => 'Propriedades do elemento'; + + @override + String get wsTopicProperties => 'Propriedades do tópico'; + + @override + String get wsCollapsible => 'Recolhível'; + + @override + String get wsPublishedState => 'Estado inicial publicado'; + + @override + String get wsExpanded => 'Expandido'; + + @override + String get wsCollapsed => 'Recolhido'; + + @override + String get wsCollapsedTitle => 'Título recolhido'; + + @override + String get wsTerm => 'Termo'; + + @override + String get wsListLayout => 'Disposição da lista'; + + @override + String get wsSyncGroup => 'Grupo de sincronização'; + + @override + String get wsTabKey => 'Chave do separador'; + + @override + String get wsSwitcherLabel => 'Etiqueta do seletor'; + + @override + String get wsNavigationElements => 'Elementos de navegação'; + + @override + String get wsNavigationDepth => 'Profundidade de navegação'; + + @override + String get wsAddItem => 'Adicionar item'; + + @override + String get wsStep => 'Passo'; + + @override + String get wsPropertyInvalid => 'Valor de propriedade inválido'; + + @override + String get wsFilter => 'Filtro'; + + @override + String get wsIdentifier => 'ID do elemento'; + + @override + String get wsTldr => 'Resumo (TLDR)'; + + @override + String get wsFullLayout => 'Largura total'; + + @override + String get wsMediumLayout => 'Colunas iguais'; + + @override + String get wsNarrowLayout => 'Coluna de termo estreita'; + + @override + String get wsTitle => 'Título'; + + @override + String get wsSwitcherKey => 'Chave do seletor'; + + @override + String get nextcloudServerSettings => 'Definições de Notes do servidor'; + + @override + String get nextcloudNotesPath => 'Caminho da pasta de notas'; + + @override + String get nextcloudFileSuffix => 'Extensão de novas notas'; + + @override + String get nextcloudServerSettingsExplanation => + 'Estas definições de Notes afetam outros clientes. Alterar o caminho não move ficheiros; alterar a extensão não renomeia notas existentes.'; + + @override + String get nextcloudSettingsSaved => 'Definições do servidor guardadas'; + + @override + String get nextcloudSettingsNormalized => + 'Guardado com valores normalizados pelo servidor'; + + @override + String get nextcloudPendingUpload => 'Envio de anexo pendente'; + + @override + String nextcloudServerCheck(String time) { + return 'Última verificação do servidor: $time'; + } + + @override + String get nextcloudSettingsLoading => 'A carregar as definições do servidor'; + + @override + String get nextcloudSettingsSaving => 'A guardar as definições do servidor'; + + @override + String get nextcloudPreviousLocalEdit => 'Edição local anterior'; + + @override + String get nextcloudUsePreviousEdit => 'Usar edição anterior'; + + @override + String get quickOpen => 'Abertura rápida'; + + @override + String get notesAll => 'Todas as notas'; + + @override + String get notesUncategorized => 'Sem categoria'; + + @override + String get notesRecovery => 'Recuperação'; + + @override + String get notesRecentlyEdited => 'Editadas recentemente'; + + @override + String get notesOldestEdited => 'Edições mais antigas'; + + @override + String get notesTitleAscending => 'Título A–Z'; + + @override + String get notesTitleDescending => 'Título Z–A'; + + @override + String get notesMove => 'Mover para categoria'; + + @override + String get notesRemoveFavorite => 'Remover favorito'; + + @override + String get notesOffline => 'Disponibilizar offline'; + + @override + String get notesOfflineRemove => 'Remover retenção offline'; + + @override + String get notesIndexing => 'A indexar notas…'; + + @override + String get notesSearchHelp => + 'Os termos usam E. Use \"frases entre aspas\", title: e category:.'; + + @override + String get notesSourceLocation => 'Localização no código fonte'; + + @override + String get notesRecoveryHelp => + 'Apenas notas locais retidas e histórico local. O lixo e o histórico do servidor não estão disponíveis.'; + + @override + String get notesImport => 'Importar notas'; + + @override + String get notesExport => 'Exportar notas'; + + @override + String get notesImportDistinct => 'Importar como nota distinta'; + + @override + String get notesAvailableOffline => 'Disponível offline'; + + @override + String get notesIncomplete => 'Incompleto'; + + @override + String get notesSelected => 'Notas selecionadas'; + + @override + String get notesNoHistory => + 'Não há revisões retidas. O histórico pode estar desativado, expirado ou apagado.'; + + @override + String get notesTextAvailable => 'Texto disponível localmente'; + + @override + String get notesLocalVersion => 'Versão de trabalho local'; + + @override + String get notesAttachments => 'Anexos'; + + @override + String get notesSkipped => 'Ignorado'; + + @override + String get notesFailed => 'Falhou'; + + @override + String get notesCompleted => 'Concluído'; + + @override + String get notesImportNew => 'Iniciar nova importação'; + + @override + String get notesImportResume => 'Retomar importação interrompida'; + + @override + String get notesImportAlreadyImported => 'Já importado nesta operação'; + + @override + String get references => 'Referências'; + + @override + String get referenceIncoming => 'Recebidas'; + + @override + String get referenceOutgoing => 'Enviadas'; + + @override + String get referenceChooseTarget => 'Escolher destino da ligação'; + + @override + String get referenceWholeDocument => 'Documento completo'; + + @override + String get referenceSourceLocation => 'Localização de origem'; + + @override + String get referenceTargetLocation => 'Localização de destino'; + + @override + String get referenceResolved => 'Resolvida'; + + @override + String get referenceMissing => 'Em falta'; + + @override + String get referenceAmbiguous => 'Ambígua'; + + @override + String get referenceUnavailable => 'Indisponível neste contexto'; + + @override + String get referenceUnsupported => 'Não suportada'; + + @override + String get referenceNoPortableId => + 'Este destino não tem um URL portátil de nota verificado.'; } /// The translations for Portuguese, as used in Brazil (`pt_BR`). @@ -9696,4 +9974,282 @@ class AppLocalizationsPtBr extends AppLocalizationsPt { @override String get nextcloudCreateSeparateWarning => 'A primeira solicitação pode já ter criado uma nota no servidor. Isso cria uma nota separada com o trabalho local preservado. Nenhuma possível duplicata será excluída.'; + + @override + String get wsSemanticFormats => 'Formatação semântica'; + + @override + String get wsUiControl => 'Controle da interface'; + + @override + String get wsFilePath => 'Arquivo / Caminho'; + + @override + String get wsUiPath => 'Caminho da interface'; + + @override + String get wsDefinitionList => 'Lista de definições'; + + @override + String get wsIncludeContent => 'Incluir conteúdo reutilizável'; + + @override + String get wsVariableReference => 'Referência de variável'; + + @override + String get wsConvertList => 'Converter lista em procedimento'; + + @override + String get wsElementProperties => 'Propriedades do elemento'; + + @override + String get wsTopicProperties => 'Propriedades do tópico'; + + @override + String get wsCollapsible => 'Recolhível'; + + @override + String get wsPublishedState => 'Estado inicial publicado'; + + @override + String get wsExpanded => 'Expandido'; + + @override + String get wsCollapsed => 'Recolhido'; + + @override + String get wsCollapsedTitle => 'Título recolhido'; + + @override + String get wsTerm => 'Termo'; + + @override + String get wsListLayout => 'Disposição da lista'; + + @override + String get wsSyncGroup => 'Grupo de sincronização'; + + @override + String get wsTabKey => 'Chave do aba'; + + @override + String get wsSwitcherLabel => 'Etiqueta do seletor'; + + @override + String get wsNavigationElements => 'Elementos de navegação'; + + @override + String get wsNavigationDepth => 'Profundidade de navegação'; + + @override + String get wsAddItem => 'Adicionar item'; + + @override + String get wsStep => 'Passo'; + + @override + String get wsPropertyInvalid => 'Valor de propriedade inválido'; + + @override + String get wsFilter => 'Filtro'; + + @override + String get wsIdentifier => 'ID do elemento'; + + @override + String get wsTldr => 'Resumo (TLDR)'; + + @override + String get wsFullLayout => 'Largura total'; + + @override + String get wsMediumLayout => 'Colunas iguais'; + + @override + String get wsNarrowLayout => 'Coluna de termo estreita'; + + @override + String get wsTitle => 'Título'; + + @override + String get wsSwitcherKey => 'Chave do seletor'; + + @override + String get nextcloudServerSettings => 'Configurações de Notes do servidor'; + + @override + String get nextcloudNotesPath => 'Caminho da pasta de notas'; + + @override + String get nextcloudFileSuffix => 'Extensão de novas notas'; + + @override + String get nextcloudServerSettingsExplanation => + 'Estas configurações de Notes afetam outros clientes. Alterar o caminho não move arquivos; alterar a extensão não renomeia notas existentes.'; + + @override + String get nextcloudSettingsSaved => 'Configurações do servidor salvas'; + + @override + String get nextcloudSettingsNormalized => + 'Salvo com valores normalizados pelo servidor'; + + @override + String get nextcloudPendingUpload => 'Envio de anexo pendente'; + + @override + String nextcloudServerCheck(String time) { + return 'Última verificação do servidor: $time'; + } + + @override + String get nextcloudSettingsLoading => 'Carregando configurações do servidor'; + + @override + String get nextcloudSettingsSaving => 'Salvando configurações do servidor'; + + @override + String get nextcloudPreviousLocalEdit => 'Edição local anterior'; + + @override + String get nextcloudUsePreviousEdit => 'Usar edição anterior'; + + @override + String get quickOpen => 'Abertura rápida'; + + @override + String get notesAll => 'Todas as notas'; + + @override + String get notesUncategorized => 'Sem categoria'; + + @override + String get notesRecovery => 'Recuperação'; + + @override + String get notesRecentlyEdited => 'Editadas recentemente'; + + @override + String get notesOldestEdited => 'Edições mais antigas'; + + @override + String get notesTitleAscending => 'Título A–Z'; + + @override + String get notesTitleDescending => 'Título Z–A'; + + @override + String get notesMove => 'Mover para categoria'; + + @override + String get notesRemoveFavorite => 'Remover favorito'; + + @override + String get notesOffline => 'Disponibilizar offline'; + + @override + String get notesOfflineRemove => 'Remover retenção offline'; + + @override + String get notesIndexing => 'Indexando notas…'; + + @override + String get notesSearchHelp => + 'Os termos usam E. Use \"frases entre aspas\", title: e category:.'; + + @override + String get notesSourceLocation => 'Localização no código fonte'; + + @override + String get notesRecoveryHelp => + 'Apenas notas locais retidas e histórico local. O lixo e o histórico do servidor não estão disponíveis.'; + + @override + String get notesImport => 'Importar notas'; + + @override + String get notesExport => 'Exportar notas'; + + @override + String get notesImportDistinct => 'Importar como nota distinta'; + + @override + String get notesAvailableOffline => 'Disponível offline'; + + @override + String get notesIncomplete => 'Incompleto'; + + @override + String get notesSelected => 'Notas selecionadas'; + + @override + String get notesNoHistory => + 'Não há revisões retidas. O histórico pode estar desativado, expirado ou apagado.'; + + @override + String get notesTextAvailable => 'Texto disponível localmente'; + + @override + String get notesLocalVersion => 'Versão de trabalho local'; + + @override + String get notesAttachments => 'Anexos'; + + @override + String get notesSkipped => 'Ignorado'; + + @override + String get notesFailed => 'Falhou'; + + @override + String get notesCompleted => 'Concluído'; + + @override + String get notesImportNew => 'Iniciar nova importação'; + + @override + String get notesImportResume => 'Retomar importação interrompida'; + + @override + String get notesImportAlreadyImported => 'Já importado nesta operação'; + + @override + String get references => 'Referências'; + + @override + String get referenceIncoming => 'Recebidas'; + + @override + String get referenceOutgoing => 'Enviadas'; + + @override + String get referenceChooseTarget => 'Escolher destino do link'; + + @override + String get referenceWholeDocument => 'Documento completo'; + + @override + String get referenceSourceLocation => 'Local de origem'; + + @override + String get referenceTargetLocation => 'Local de destino'; + + @override + String get referenceResolved => 'Resolvida'; + + @override + String get referenceMissing => 'Ausente'; + + @override + String get referenceAmbiguous => 'Ambígua'; + + @override + String get referenceUnavailable => 'Indisponível neste contexto'; + + @override + String get referenceUnsupported => 'Não compatível'; + + @override + String get referenceNoPortableId => + 'Este destino não tem uma URL portátil de nota verificada.'; } diff --git a/lib/l10n/generated/app_localizations_ru.dart b/lib/l10n/generated/app_localizations_ru.dart index 68d792a0..30604b9c 100644 --- a/lib/l10n/generated/app_localizations_ru.dart +++ b/lib/l10n/generated/app_localizations_ru.dart @@ -4847,4 +4847,282 @@ class AppLocalizationsRu extends AppLocalizations { @override String get nextcloudCreateSeparateWarning => 'Первый запрос мог уже создать заметку на сервере. Будет создана отдельная заметка с сохранённой локальной работой. Предполагаемые дубликаты не будут удалены.'; + + @override + String get wsSemanticFormats => 'Семантическое форматирование'; + + @override + String get wsUiControl => 'Элемент интерфейса'; + + @override + String get wsFilePath => 'Файл / Путь'; + + @override + String get wsUiPath => 'Путь в интерфейсе'; + + @override + String get wsDefinitionList => 'Список определений'; + + @override + String get wsIncludeContent => 'Включить повторно используемый контент'; + + @override + String get wsVariableReference => 'Ссылка на переменную'; + + @override + String get wsConvertList => 'Преобразовать список в процедуру'; + + @override + String get wsElementProperties => 'Свойства элемента'; + + @override + String get wsTopicProperties => 'Свойства темы'; + + @override + String get wsCollapsible => 'Сворачиваемый'; + + @override + String get wsPublishedState => 'Начальное состояние публикации'; + + @override + String get wsExpanded => 'Развёрнуто'; + + @override + String get wsCollapsed => 'Свёрнуто'; + + @override + String get wsCollapsedTitle => 'Заголовок в свёрнутом виде'; + + @override + String get wsTerm => 'Термин'; + + @override + String get wsListLayout => 'Макет списка'; + + @override + String get wsSyncGroup => 'Группа синхронизации'; + + @override + String get wsTabKey => 'Ключ вкладки'; + + @override + String get wsSwitcherLabel => 'Подпись переключателя'; + + @override + String get wsNavigationElements => 'Элементы навигации'; + + @override + String get wsNavigationDepth => 'Глубина навигации'; + + @override + String get wsAddItem => 'Добавить элемент'; + + @override + String get wsStep => 'Шаг'; + + @override + String get wsPropertyInvalid => 'Недопустимое значение свойства'; + + @override + String get wsFilter => 'Фильтр'; + + @override + String get wsIdentifier => 'Идентификатор элемента'; + + @override + String get wsTldr => 'Краткое содержание (TLDR)'; + + @override + String get wsFullLayout => 'Полная ширина'; + + @override + String get wsMediumLayout => 'Равные столбцы'; + + @override + String get wsNarrowLayout => 'Узкий столбец термина'; + + @override + String get wsTitle => 'Заголовок'; + + @override + String get wsSwitcherKey => 'Ключ переключателя'; + + @override + String get nextcloudServerSettings => 'Настройки Notes на сервере'; + + @override + String get nextcloudNotesPath => 'Путь к папке заметок'; + + @override + String get nextcloudFileSuffix => 'Расширение новых заметок'; + + @override + String get nextcloudServerSettingsExplanation => + 'Эти настройки Notes влияют на другие клиенты. Изменение пути не перемещает файлы; изменение расширения не переименовывает существующие заметки.'; + + @override + String get nextcloudSettingsSaved => 'Настройки сервера сохранены'; + + @override + String get nextcloudSettingsNormalized => + 'Сохранено со значениями, нормализованными сервером'; + + @override + String get nextcloudPendingUpload => 'Вложение ожидает загрузки'; + + @override + String nextcloudServerCheck(String time) { + return 'Последняя проверка сервера: $time'; + } + + @override + String get nextcloudSettingsLoading => 'Загрузка настроек сервера'; + + @override + String get nextcloudSettingsSaving => 'Сохранение настроек сервера'; + + @override + String get nextcloudPreviousLocalEdit => 'Предыдущая локальная правка'; + + @override + String get nextcloudUsePreviousEdit => 'Использовать предыдущую правку'; + + @override + String get quickOpen => 'Быстрое открытие'; + + @override + String get notesAll => 'Все заметки'; + + @override + String get notesUncategorized => 'Без категории'; + + @override + String get notesRecovery => 'Восстановление'; + + @override + String get notesRecentlyEdited => 'Недавно изменённые'; + + @override + String get notesOldestEdited => 'Давно изменённые'; + + @override + String get notesTitleAscending => 'Название А–Я'; + + @override + String get notesTitleDescending => 'Название Я–А'; + + @override + String get notesMove => 'Переместить в категорию'; + + @override + String get notesRemoveFavorite => 'Убрать из избранного'; + + @override + String get notesOffline => 'Сохранить для работы без сети'; + + @override + String get notesOfflineRemove => 'Убрать сохранение без сети'; + + @override + String get notesIndexing => 'Индексация заметок…'; + + @override + String get notesSearchHelp => + 'Термины объединяются И. Используйте \"фразы в кавычках\", title: и category:.'; + + @override + String get notesSourceLocation => 'Место в исходнике'; + + @override + String get notesRecoveryHelp => + 'Только сохранённые локальные заметки и локальная история. Корзина и история сервера недоступны.'; + + @override + String get notesImport => 'Импорт заметок'; + + @override + String get notesExport => 'Экспорт заметок'; + + @override + String get notesImportDistinct => 'Импортировать как отдельную заметку'; + + @override + String get notesAvailableOffline => 'Доступно без сети'; + + @override + String get notesIncomplete => 'Неполно'; + + @override + String get notesSelected => 'Выбранные заметки'; + + @override + String get notesNoHistory => + 'Нет сохранённых версий. История может быть отключена, устарела или очищена.'; + + @override + String get notesTextAvailable => 'Текст доступен локально'; + + @override + String get notesLocalVersion => 'Локальная рабочая версия'; + + @override + String get notesAttachments => 'Вложения'; + + @override + String get notesSkipped => 'Пропущено'; + + @override + String get notesFailed => 'Ошибка'; + + @override + String get notesCompleted => 'Завершено'; + + @override + String get notesImportNew => 'Начать новый импорт'; + + @override + String get notesImportResume => 'Продолжить прерванный импорт'; + + @override + String get notesImportAlreadyImported => 'Уже импортировано в этой операции'; + + @override + String get references => 'Ссылки'; + + @override + String get referenceIncoming => 'Входящие'; + + @override + String get referenceOutgoing => 'Исходящие'; + + @override + String get referenceChooseTarget => 'Выберите цель ссылки'; + + @override + String get referenceWholeDocument => 'Весь документ'; + + @override + String get referenceSourceLocation => 'Положение источника'; + + @override + String get referenceTargetLocation => 'Положение цели'; + + @override + String get referenceResolved => 'Найдена'; + + @override + String get referenceMissing => 'Отсутствует'; + + @override + String get referenceAmbiguous => 'Неоднозначная'; + + @override + String get referenceUnavailable => 'Недоступна в этом контексте'; + + @override + String get referenceUnsupported => 'Не поддерживается'; + + @override + String get referenceNoPortableId => + 'У этой цели нет проверенного переносимого URL заметки.'; } diff --git a/lib/l10n/generated/app_localizations_tr.dart b/lib/l10n/generated/app_localizations_tr.dart index a4ef4597..5064400c 100644 --- a/lib/l10n/generated/app_localizations_tr.dart +++ b/lib/l10n/generated/app_localizations_tr.dart @@ -4790,4 +4790,282 @@ class AppLocalizationsTr extends AppLocalizations { @override String get nextcloudCreateSeparateWarning => 'İlk istek sunucuda zaten bir not oluşturmuş olabilir. Bu işlem korunan yerel çalışmanızla ayrı bir not oluşturur. Olası kopyalar silinmez.'; + + @override + String get wsSemanticFormats => 'Anlamsal biçimlendirme'; + + @override + String get wsUiControl => 'Arayüz denetimi'; + + @override + String get wsFilePath => 'Dosya / Yol'; + + @override + String get wsUiPath => 'Arayüz yolu'; + + @override + String get wsDefinitionList => 'Tanım listesi'; + + @override + String get wsIncludeContent => 'Yeniden kullanılabilir içerik ekle'; + + @override + String get wsVariableReference => 'Değişken başvurusu'; + + @override + String get wsConvertList => 'Listeyi prosedüre dönüştür'; + + @override + String get wsElementProperties => 'Öğe özellikleri'; + + @override + String get wsTopicProperties => 'Konu özellikleri'; + + @override + String get wsCollapsible => 'Daraltılabilir'; + + @override + String get wsPublishedState => 'Yayımlanan başlangıç durumu'; + + @override + String get wsExpanded => 'Genişletilmiş'; + + @override + String get wsCollapsed => 'Daraltılmış'; + + @override + String get wsCollapsedTitle => 'Daraltılmış başlık'; + + @override + String get wsTerm => 'Terim'; + + @override + String get wsListLayout => 'Liste düzeni'; + + @override + String get wsSyncGroup => 'Eşitleme grubu'; + + @override + String get wsTabKey => 'Sekme anahtarı'; + + @override + String get wsSwitcherLabel => 'Değiştirici etiketi'; + + @override + String get wsNavigationElements => 'Gezinme öğeleri'; + + @override + String get wsNavigationDepth => 'Gezinme derinliği'; + + @override + String get wsAddItem => 'Öğe ekle'; + + @override + String get wsStep => 'Adım'; + + @override + String get wsPropertyInvalid => 'Geçersiz özellik değeri'; + + @override + String get wsFilter => 'Filtre'; + + @override + String get wsIdentifier => 'Öğe kimliği'; + + @override + String get wsTldr => 'Özet (TLDR)'; + + @override + String get wsFullLayout => 'Tam genişlik'; + + @override + String get wsMediumLayout => 'Eşit sütunlar'; + + @override + String get wsNarrowLayout => 'Dar terim sütunu'; + + @override + String get wsTitle => 'Başlık'; + + @override + String get wsSwitcherKey => 'Değiştirici anahtarı'; + + @override + String get nextcloudServerSettings => 'Sunucu Notes ayarları'; + + @override + String get nextcloudNotesPath => 'Not klasörünün yolu'; + + @override + String get nextcloudFileSuffix => 'Yeni notların dosya uzantısı'; + + @override + String get nextcloudServerSettingsExplanation => + 'Bu sunucu Notes ayarları diğer istemcileri de etkiler. Yolun değiştirilmesi dosyaları taşımaz; uzantının değiştirilmesi mevcut notları yeniden adlandırmaz.'; + + @override + String get nextcloudSettingsSaved => 'Sunucu ayarları kaydedildi'; + + @override + String get nextcloudSettingsNormalized => + 'Sunucunun normalleştirdiği değerlerle kaydedildi'; + + @override + String get nextcloudPendingUpload => 'Ek yüklemesi bekliyor'; + + @override + String nextcloudServerCheck(String time) { + return 'Son sunucu kontrolü: $time'; + } + + @override + String get nextcloudSettingsLoading => 'Sunucu ayarları yükleniyor'; + + @override + String get nextcloudSettingsSaving => 'Sunucu ayarları kaydediliyor'; + + @override + String get nextcloudPreviousLocalEdit => 'Önceki yerel düzenleme'; + + @override + String get nextcloudUsePreviousEdit => 'Önceki düzenlemeyi kullan'; + + @override + String get quickOpen => 'Hızlı aç'; + + @override + String get notesAll => 'Tüm notlar'; + + @override + String get notesUncategorized => 'Kategorisiz'; + + @override + String get notesRecovery => 'Kurtarma'; + + @override + String get notesRecentlyEdited => 'Son düzenlenen'; + + @override + String get notesOldestEdited => 'En eski düzenleme'; + + @override + String get notesTitleAscending => 'Başlık A–Z'; + + @override + String get notesTitleDescending => 'Başlık Z–A'; + + @override + String get notesMove => 'Kategoriye taşı'; + + @override + String get notesRemoveFavorite => 'Favoriyi kaldır'; + + @override + String get notesOffline => 'Çevrimdışı kullanılabilir yap'; + + @override + String get notesOfflineRemove => 'Çevrimdışı saklamayı kaldır'; + + @override + String get notesIndexing => 'Notlar dizine ekleniyor…'; + + @override + String get notesSearchHelp => + 'Terimler VE ile birleştirilir. \"Alıntı ifadeler\", title: ve category: kullanın.'; + + @override + String get notesSourceLocation => 'Kaynak konumu'; + + @override + String get notesRecoveryHelp => + 'Yalnızca saklanan yerel notlar ve yerel geçmiş. Sunucu çöp kutusu ve geçmişi kullanılamaz.'; + + @override + String get notesImport => 'Notları içe aktar'; + + @override + String get notesExport => 'Notları dışa aktar'; + + @override + String get notesImportDistinct => 'Ayrı bir not olarak içe aktar'; + + @override + String get notesAvailableOffline => 'Çevrimdışı kullanılabilir'; + + @override + String get notesIncomplete => 'Eksik'; + + @override + String get notesSelected => 'Seçilen notlar'; + + @override + String get notesNoHistory => + 'Saklanan sürüm yok. Geçmiş kapalı, süresi dolmuş veya temizlenmiş olabilir.'; + + @override + String get notesTextAvailable => 'Metin yerel olarak kullanılabilir'; + + @override + String get notesLocalVersion => 'Yerel çalışma sürümü'; + + @override + String get notesAttachments => 'Ekler'; + + @override + String get notesSkipped => 'Atlandı'; + + @override + String get notesFailed => 'Başarısız'; + + @override + String get notesCompleted => 'Tamamlandı'; + + @override + String get notesImportNew => 'Yeni içe aktarma başlat'; + + @override + String get notesImportResume => 'Kesilen içe aktarmayı sürdür'; + + @override + String get notesImportAlreadyImported => 'Bu işlemde zaten içe aktarıldı'; + + @override + String get references => 'Başvurular'; + + @override + String get referenceIncoming => 'Gelen'; + + @override + String get referenceOutgoing => 'Giden'; + + @override + String get referenceChooseTarget => 'Bağlantı hedefini seç'; + + @override + String get referenceWholeDocument => 'Tüm belge'; + + @override + String get referenceSourceLocation => 'Kaynak konumu'; + + @override + String get referenceTargetLocation => 'Hedef konumu'; + + @override + String get referenceResolved => 'Çözüldü'; + + @override + String get referenceMissing => 'Eksik'; + + @override + String get referenceAmbiguous => 'Belirsiz'; + + @override + String get referenceUnavailable => 'Bu bağlamda kullanılamıyor'; + + @override + String get referenceUnsupported => 'Desteklenmiyor'; + + @override + String get referenceNoPortableId => + 'Bu hedefin doğrulanmış taşınabilir not URL’si yok.'; } diff --git a/lib/l10n/generated/app_localizations_uk.dart b/lib/l10n/generated/app_localizations_uk.dart index 830590c4..ffcff630 100644 --- a/lib/l10n/generated/app_localizations_uk.dart +++ b/lib/l10n/generated/app_localizations_uk.dart @@ -4857,4 +4857,282 @@ class AppLocalizationsUk extends AppLocalizations { @override String get nextcloudCreateSeparateWarning => 'Перший запит міг уже створити нотатку на сервері. Буде створено окрему нотатку зі збереженою локальною роботою. Ймовірні дублікати не буде видалено.'; + + @override + String get wsSemanticFormats => 'Семантичне форматування'; + + @override + String get wsUiControl => 'Елемент інтерфейсу'; + + @override + String get wsFilePath => 'Файл / Шлях'; + + @override + String get wsUiPath => 'Шлях в інтерфейсі'; + + @override + String get wsDefinitionList => 'Список визначень'; + + @override + String get wsIncludeContent => 'Додати повторно використовуваний вміст'; + + @override + String get wsVariableReference => 'Посилання на змінну'; + + @override + String get wsConvertList => 'Перетворити список на процедуру'; + + @override + String get wsElementProperties => 'Властивості елемента'; + + @override + String get wsTopicProperties => 'Властивості теми'; + + @override + String get wsCollapsible => 'Згортання'; + + @override + String get wsPublishedState => 'Початковий стан публікації'; + + @override + String get wsExpanded => 'Розгорнуто'; + + @override + String get wsCollapsed => 'Згорнуто'; + + @override + String get wsCollapsedTitle => 'Заголовок у згорнутому стані'; + + @override + String get wsTerm => 'Термін'; + + @override + String get wsListLayout => 'Макет списку'; + + @override + String get wsSyncGroup => 'Група синхронізації'; + + @override + String get wsTabKey => 'Ключ вкладки'; + + @override + String get wsSwitcherLabel => 'Підпис перемикача'; + + @override + String get wsNavigationElements => 'Елементи навігації'; + + @override + String get wsNavigationDepth => 'Глибина навігації'; + + @override + String get wsAddItem => 'Додати елемент'; + + @override + String get wsStep => 'Крок'; + + @override + String get wsPropertyInvalid => 'Неприпустиме значення властивості'; + + @override + String get wsFilter => 'Фільтр'; + + @override + String get wsIdentifier => 'Ідентифікатор елемента'; + + @override + String get wsTldr => 'Короткий виклад (TLDR)'; + + @override + String get wsFullLayout => 'Повна ширина'; + + @override + String get wsMediumLayout => 'Рівні стовпці'; + + @override + String get wsNarrowLayout => 'Вузький стовпець терміна'; + + @override + String get wsTitle => 'Заголовок'; + + @override + String get wsSwitcherKey => 'Ключ перемикача'; + + @override + String get nextcloudServerSettings => 'Налаштування Notes на сервері'; + + @override + String get nextcloudNotesPath => 'Шлях до теки нотаток'; + + @override + String get nextcloudFileSuffix => 'Розширення нових нотаток'; + + @override + String get nextcloudServerSettingsExplanation => + 'Ці налаштування Notes впливають на інші клієнти. Зміна шляху не переміщує файли; зміна розширення не перейменовує наявні нотатки.'; + + @override + String get nextcloudSettingsSaved => 'Налаштування сервера збережено'; + + @override + String get nextcloudSettingsNormalized => + 'Збережено зі значеннями, нормалізованими сервером'; + + @override + String get nextcloudPendingUpload => 'Вкладення очікує завантаження'; + + @override + String nextcloudServerCheck(String time) { + return 'Остання перевірка сервера: $time'; + } + + @override + String get nextcloudSettingsLoading => 'Завантаження налаштувань сервера'; + + @override + String get nextcloudSettingsSaving => 'Збереження налаштувань сервера'; + + @override + String get nextcloudPreviousLocalEdit => 'Попередня локальна зміна'; + + @override + String get nextcloudUsePreviousEdit => 'Використати попередню зміну'; + + @override + String get quickOpen => 'Швидке відкриття'; + + @override + String get notesAll => 'Усі нотатки'; + + @override + String get notesUncategorized => 'Без категорії'; + + @override + String get notesRecovery => 'Відновлення'; + + @override + String get notesRecentlyEdited => 'Нещодавно змінені'; + + @override + String get notesOldestEdited => 'Давно змінені'; + + @override + String get notesTitleAscending => 'Назва А–Я'; + + @override + String get notesTitleDescending => 'Назва Я–А'; + + @override + String get notesMove => 'Перемістити до категорії'; + + @override + String get notesRemoveFavorite => 'Вилучити з обраного'; + + @override + String get notesOffline => 'Зберегти для роботи без мережі'; + + @override + String get notesOfflineRemove => 'Вилучити збереження без мережі'; + + @override + String get notesIndexing => 'Індексація нотаток…'; + + @override + String get notesSearchHelp => + 'Терміни поєднуються І. Використовуйте \"фрази в лапках\", title: та category:.'; + + @override + String get notesSourceLocation => 'Місце в джерелі'; + + @override + String get notesRecoveryHelp => + 'Лише збережені локальні нотатки та локальна історія. Кошик та історія сервера недоступні.'; + + @override + String get notesImport => 'Імпорт нотаток'; + + @override + String get notesExport => 'Експорт нотаток'; + + @override + String get notesImportDistinct => 'Імпортувати як окрему нотатку'; + + @override + String get notesAvailableOffline => 'Доступно без мережі'; + + @override + String get notesIncomplete => 'Неповно'; + + @override + String get notesSelected => 'Вибрані нотатки'; + + @override + String get notesNoHistory => + 'Немає збережених версій. Історію може бути вимкнено, прострочено або очищено.'; + + @override + String get notesTextAvailable => 'Текст доступний локально'; + + @override + String get notesLocalVersion => 'Локальна робоча версія'; + + @override + String get notesAttachments => 'Вкладення'; + + @override + String get notesSkipped => 'Пропущено'; + + @override + String get notesFailed => 'Помилка'; + + @override + String get notesCompleted => 'Завершено'; + + @override + String get notesImportNew => 'Почати новий імпорт'; + + @override + String get notesImportResume => 'Продовжити перерваний імпорт'; + + @override + String get notesImportAlreadyImported => 'Уже імпортовано в цій операції'; + + @override + String get references => 'Посилання'; + + @override + String get referenceIncoming => 'Вхідні'; + + @override + String get referenceOutgoing => 'Вихідні'; + + @override + String get referenceChooseTarget => 'Виберіть ціль посилання'; + + @override + String get referenceWholeDocument => 'Увесь документ'; + + @override + String get referenceSourceLocation => 'Позиція джерела'; + + @override + String get referenceTargetLocation => 'Позиція цілі'; + + @override + String get referenceResolved => 'Знайдено'; + + @override + String get referenceMissing => 'Відсутнє'; + + @override + String get referenceAmbiguous => 'Неоднозначне'; + + @override + String get referenceUnavailable => 'Недоступне в цьому контексті'; + + @override + String get referenceUnsupported => 'Не підтримується'; + + @override + String get referenceNoPortableId => + 'Ця ціль не має перевіреної переносної URL-адреси нотатки.'; } diff --git a/lib/l10n/generated/app_localizations_vi.dart b/lib/l10n/generated/app_localizations_vi.dart index 7fbddeeb..a8930ac8 100644 --- a/lib/l10n/generated/app_localizations_vi.dart +++ b/lib/l10n/generated/app_localizations_vi.dart @@ -4780,4 +4780,282 @@ class AppLocalizationsVi extends AppLocalizations { @override String get nextcloudCreateSeparateWarning => 'Yêu cầu đầu tiên có thể đã tạo ghi chú trên máy chủ. Thao tác này tạo ghi chú riêng với công việc cục bộ được giữ lại. Không xóa ghi chú nào bị nghi trùng lặp.'; + + @override + String get wsSemanticFormats => 'Định dạng ngữ nghĩa'; + + @override + String get wsUiControl => 'Điều khiển giao diện'; + + @override + String get wsFilePath => 'Tệp / Đường dẫn'; + + @override + String get wsUiPath => 'Đường dẫn giao diện'; + + @override + String get wsDefinitionList => 'Danh sách định nghĩa'; + + @override + String get wsIncludeContent => 'Chèn nội dung tái sử dụng'; + + @override + String get wsVariableReference => 'Tham chiếu biến'; + + @override + String get wsConvertList => 'Chuyển danh sách thành quy trình'; + + @override + String get wsElementProperties => 'Thuộc tính phần tử'; + + @override + String get wsTopicProperties => 'Thuộc tính chủ đề'; + + @override + String get wsCollapsible => 'Có thể thu gọn'; + + @override + String get wsPublishedState => 'Trạng thái ban đầu khi xuất bản'; + + @override + String get wsExpanded => 'Mở rộng'; + + @override + String get wsCollapsed => 'Thu gọn'; + + @override + String get wsCollapsedTitle => 'Tiêu đề thu gọn'; + + @override + String get wsTerm => 'Thuật ngữ'; + + @override + String get wsListLayout => 'Bố cục danh sách'; + + @override + String get wsSyncGroup => 'Nhóm đồng bộ'; + + @override + String get wsTabKey => 'Khóa thẻ'; + + @override + String get wsSwitcherLabel => 'Nhãn bộ chuyển'; + + @override + String get wsNavigationElements => 'Phần tử điều hướng'; + + @override + String get wsNavigationDepth => 'Độ sâu điều hướng'; + + @override + String get wsAddItem => 'Thêm mục'; + + @override + String get wsStep => 'Bước'; + + @override + String get wsPropertyInvalid => 'Giá trị thuộc tính không hợp lệ'; + + @override + String get wsFilter => 'Bộ lọc'; + + @override + String get wsIdentifier => 'ID phần tử'; + + @override + String get wsTldr => 'Tóm tắt (TLDR)'; + + @override + String get wsFullLayout => 'Toàn chiều rộng'; + + @override + String get wsMediumLayout => 'Các cột bằng nhau'; + + @override + String get wsNarrowLayout => 'Cột thuật ngữ hẹp'; + + @override + String get wsTitle => 'Tiêu đề'; + + @override + String get wsSwitcherKey => 'Khóa chuyển đổi'; + + @override + String get nextcloudServerSettings => 'Cài đặt Notes trên máy chủ'; + + @override + String get nextcloudNotesPath => 'Đường dẫn thư mục ghi chú'; + + @override + String get nextcloudFileSuffix => 'Phần mở rộng của ghi chú mới'; + + @override + String get nextcloudServerSettingsExplanation => + 'Các cài đặt Notes này ảnh hưởng đến các ứng dụng khách khác. Đổi đường dẫn không di chuyển tệp; đổi phần mở rộng không đổi tên ghi chú hiện có.'; + + @override + String get nextcloudSettingsSaved => 'Đã lưu cài đặt máy chủ'; + + @override + String get nextcloudSettingsNormalized => + 'Đã lưu các giá trị được máy chủ chuẩn hóa'; + + @override + String get nextcloudPendingUpload => 'Đang chờ tải tệp đính kèm lên'; + + @override + String nextcloudServerCheck(String time) { + return 'Lần kiểm tra máy chủ gần nhất: $time'; + } + + @override + String get nextcloudSettingsLoading => 'Đang tải cài đặt máy chủ'; + + @override + String get nextcloudSettingsSaving => 'Đang lưu cài đặt máy chủ'; + + @override + String get nextcloudPreviousLocalEdit => 'Chỉnh sửa cục bộ trước'; + + @override + String get nextcloudUsePreviousEdit => 'Dùng chỉnh sửa trước'; + + @override + String get quickOpen => 'Mở nhanh'; + + @override + String get notesAll => 'Tất cả ghi chú'; + + @override + String get notesUncategorized => 'Chưa phân loại'; + + @override + String get notesRecovery => 'Khôi phục'; + + @override + String get notesRecentlyEdited => 'Sửa gần đây'; + + @override + String get notesOldestEdited => 'Sửa lâu nhất'; + + @override + String get notesTitleAscending => 'Tiêu đề A–Z'; + + @override + String get notesTitleDescending => 'Tiêu đề Z–A'; + + @override + String get notesMove => 'Chuyển tới danh mục'; + + @override + String get notesRemoveFavorite => 'Bỏ yêu thích'; + + @override + String get notesOffline => 'Cho phép dùng ngoại tuyến'; + + @override + String get notesOfflineRemove => 'Bỏ yêu cầu lưu ngoại tuyến'; + + @override + String get notesIndexing => 'Đang lập chỉ mục ghi chú…'; + + @override + String get notesSearchHelp => + 'Các từ kết hợp bằng VÀ. Dùng \"cụm từ trích dẫn\", title: và category:.'; + + @override + String get notesSourceLocation => 'Vị trí mã nguồn'; + + @override + String get notesRecoveryHelp => + 'Chỉ gồm ghi chú cục bộ được giữ và lịch sử cục bộ. Không có thùng rác hoặc lịch sử máy chủ.'; + + @override + String get notesImport => 'Nhập ghi chú'; + + @override + String get notesExport => 'Xuất ghi chú'; + + @override + String get notesImportDistinct => 'Nhập thành ghi chú riêng'; + + @override + String get notesAvailableOffline => 'Có sẵn ngoại tuyến'; + + @override + String get notesIncomplete => 'Chưa đầy đủ'; + + @override + String get notesSelected => 'Ghi chú đã chọn'; + + @override + String get notesNoHistory => + 'Không có phiên bản được giữ. Lịch sử có thể bị tắt, hết hạn hoặc bị xóa.'; + + @override + String get notesTextAvailable => 'Văn bản có sẵn cục bộ'; + + @override + String get notesLocalVersion => 'Phiên bản làm việc cục bộ'; + + @override + String get notesAttachments => 'Tệp đính kèm'; + + @override + String get notesSkipped => 'Đã bỏ qua'; + + @override + String get notesFailed => 'Thất bại'; + + @override + String get notesCompleted => 'Hoàn tất'; + + @override + String get notesImportNew => 'Bắt đầu nhập mới'; + + @override + String get notesImportResume => 'Tiếp tục lần nhập bị gián đoạn'; + + @override + String get notesImportAlreadyImported => 'Đã nhập trong thao tác này'; + + @override + String get references => 'Tham chiếu'; + + @override + String get referenceIncoming => 'Đến'; + + @override + String get referenceOutgoing => 'Đi'; + + @override + String get referenceChooseTarget => 'Chọn đích liên kết'; + + @override + String get referenceWholeDocument => 'Toàn bộ tài liệu'; + + @override + String get referenceSourceLocation => 'Vị trí nguồn'; + + @override + String get referenceTargetLocation => 'Vị trí đích'; + + @override + String get referenceResolved => 'Đã phân giải'; + + @override + String get referenceMissing => 'Không tìm thấy'; + + @override + String get referenceAmbiguous => 'Không rõ ràng'; + + @override + String get referenceUnavailable => 'Không khả dụng trong ngữ cảnh này'; + + @override + String get referenceUnsupported => 'Không được hỗ trợ'; + + @override + String get referenceNoPortableId => + 'Đích này không có URL ghi chú di động đã xác minh.'; } diff --git a/lib/l10n/generated/app_localizations_zh.dart b/lib/l10n/generated/app_localizations_zh.dart index 005f0425..3b500d52 100644 --- a/lib/l10n/generated/app_localizations_zh.dart +++ b/lib/l10n/generated/app_localizations_zh.dart @@ -4577,6 +4577,279 @@ class AppLocalizationsZh extends AppLocalizations { @override String get nextcloudCreateSeparateWarning => '首次请求可能已在服务器上创建笔记。此操作将使用保留的本地工作创建独立笔记。不会删除任何疑似重复的笔记。'; + + @override + String get wsSemanticFormats => '语义格式'; + + @override + String get wsUiControl => '界面控件'; + + @override + String get wsFilePath => '文件 / 路径'; + + @override + String get wsUiPath => '界面路径'; + + @override + String get wsDefinitionList => '定义列表'; + + @override + String get wsIncludeContent => '包含可复用内容'; + + @override + String get wsVariableReference => '变量引用'; + + @override + String get wsConvertList => '将列表转换为过程'; + + @override + String get wsElementProperties => '元素属性'; + + @override + String get wsTopicProperties => '主题属性'; + + @override + String get wsCollapsible => '可折叠'; + + @override + String get wsPublishedState => '发布时的初始状态'; + + @override + String get wsExpanded => '展开'; + + @override + String get wsCollapsed => '折叠'; + + @override + String get wsCollapsedTitle => '折叠标题'; + + @override + String get wsTerm => '术语'; + + @override + String get wsListLayout => '列表布局'; + + @override + String get wsSyncGroup => '同步组'; + + @override + String get wsTabKey => '选项卡键'; + + @override + String get wsSwitcherLabel => '切换器标签'; + + @override + String get wsNavigationElements => '导航元素'; + + @override + String get wsNavigationDepth => '导航深度'; + + @override + String get wsAddItem => '添加项目'; + + @override + String get wsStep => '步骤'; + + @override + String get wsPropertyInvalid => '属性值无效'; + + @override + String get wsFilter => '筛选器'; + + @override + String get wsIdentifier => '元素 ID'; + + @override + String get wsTldr => '摘要 (TLDR)'; + + @override + String get wsFullLayout => '全宽'; + + @override + String get wsMediumLayout => '等宽列'; + + @override + String get wsNarrowLayout => '窄术语列'; + + @override + String get wsTitle => '标题'; + + @override + String get wsSwitcherKey => '切换键'; + + @override + String get nextcloudServerSettings => '服务器 Notes 设置'; + + @override + String get nextcloudNotesPath => '笔记文件夹路径'; + + @override + String get nextcloudFileSuffix => '新笔记的文件后缀'; + + @override + String get nextcloudServerSettingsExplanation => + '这些服务器 Notes 设置会影响其他客户端。更改路径不会移动文件;更改后缀不会重命名现有笔记。'; + + @override + String get nextcloudSettingsSaved => '服务器设置已保存'; + + @override + String get nextcloudSettingsNormalized => '已保存服务器规范化后的值'; + + @override + String get nextcloudPendingUpload => '附件等待上传'; + + @override + String nextcloudServerCheck(String time) { + return '上次服务器检查:$time'; + } + + @override + String get nextcloudSettingsLoading => '正在加载服务器设置'; + + @override + String get nextcloudSettingsSaving => '正在保存服务器设置'; + + @override + String get nextcloudPreviousLocalEdit => '先前的本地编辑'; + + @override + String get nextcloudUsePreviousEdit => '使用先前编辑'; + + @override + String get quickOpen => '快速打开'; + + @override + String get notesAll => '所有笔记'; + + @override + String get notesUncategorized => '未分类'; + + @override + String get notesRecovery => '恢复'; + + @override + String get notesRecentlyEdited => '最近编辑'; + + @override + String get notesOldestEdited => '最早编辑'; + + @override + String get notesTitleAscending => '标题升序'; + + @override + String get notesTitleDescending => '标题降序'; + + @override + String get notesMove => '移至分类'; + + @override + String get notesRemoveFavorite => '取消收藏'; + + @override + String get notesOffline => '设为离线可用'; + + @override + String get notesOfflineRemove => '移除离线保留要求'; + + @override + String get notesIndexing => '正在索引笔记…'; + + @override + String get notesSearchHelp => '词项使用“与”组合。可使用\"引号短语\"、title: 和 category:。'; + + @override + String get notesSourceLocation => '源文件位置'; + + @override + String get notesRecoveryHelp => '仅包括保留的本地笔记和本地历史。无法访问服务器回收站或历史。'; + + @override + String get notesImport => '导入笔记'; + + @override + String get notesExport => '导出笔记'; + + @override + String get notesImportDistinct => '导入为独立笔记'; + + @override + String get notesAvailableOffline => '离线可用'; + + @override + String get notesIncomplete => '不完整'; + + @override + String get notesSelected => '已选笔记'; + + @override + String get notesNoHistory => '没有保留的版本。历史可能已禁用、过期或清除。'; + + @override + String get notesTextAvailable => '文本已在本地可用'; + + @override + String get notesLocalVersion => '本地工作版本'; + + @override + String get notesAttachments => '附件'; + + @override + String get notesSkipped => '已跳过'; + + @override + String get notesFailed => '失败'; + + @override + String get notesCompleted => '已完成'; + + @override + String get notesImportNew => '开始新导入'; + + @override + String get notesImportResume => '继续中断的导入'; + + @override + String get notesImportAlreadyImported => '已在此操作中导入'; + + @override + String get references => '引用'; + + @override + String get referenceIncoming => '传入'; + + @override + String get referenceOutgoing => '传出'; + + @override + String get referenceChooseTarget => '选择链接目标'; + + @override + String get referenceWholeDocument => '整个文档'; + + @override + String get referenceSourceLocation => '源位置'; + + @override + String get referenceTargetLocation => '目标位置'; + + @override + String get referenceResolved => '已解析'; + + @override + String get referenceMissing => '缺失'; + + @override + String get referenceAmbiguous => '不明确'; + + @override + String get referenceUnavailable => '当前上下文中不可用'; + + @override + String get referenceUnsupported => '不支持'; + + @override + String get referenceNoPortableId => '此目标没有经过验证的可移植笔记网址。'; } /// The translations for Chinese, as used in China (`zh_CN`). @@ -9150,4 +9423,277 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { @override String get nextcloudCreateSeparateWarning => '首次请求可能已在服务器上创建笔记。此操作将使用保留的本地工作创建独立笔记。不会删除任何疑似重复的笔记。'; + + @override + String get wsSemanticFormats => '语义格式'; + + @override + String get wsUiControl => '界面控件'; + + @override + String get wsFilePath => '文件 / 路径'; + + @override + String get wsUiPath => '界面路径'; + + @override + String get wsDefinitionList => '定义列表'; + + @override + String get wsIncludeContent => '包含可复用内容'; + + @override + String get wsVariableReference => '变量引用'; + + @override + String get wsConvertList => '将列表转换为过程'; + + @override + String get wsElementProperties => '元素属性'; + + @override + String get wsTopicProperties => '主题属性'; + + @override + String get wsCollapsible => '可折叠'; + + @override + String get wsPublishedState => '发布时的初始状态'; + + @override + String get wsExpanded => '展开'; + + @override + String get wsCollapsed => '折叠'; + + @override + String get wsCollapsedTitle => '折叠标题'; + + @override + String get wsTerm => '术语'; + + @override + String get wsListLayout => '列表布局'; + + @override + String get wsSyncGroup => '同步组'; + + @override + String get wsTabKey => '选项卡键'; + + @override + String get wsSwitcherLabel => '切换器标签'; + + @override + String get wsNavigationElements => '导航元素'; + + @override + String get wsNavigationDepth => '导航深度'; + + @override + String get wsAddItem => '添加项目'; + + @override + String get wsStep => '步骤'; + + @override + String get wsPropertyInvalid => '属性值无效'; + + @override + String get wsFilter => '筛选器'; + + @override + String get wsIdentifier => '元素 ID'; + + @override + String get wsTldr => '摘要 (TLDR)'; + + @override + String get wsFullLayout => '全宽'; + + @override + String get wsMediumLayout => '等宽列'; + + @override + String get wsNarrowLayout => '窄术语列'; + + @override + String get wsTitle => '标题'; + + @override + String get wsSwitcherKey => '切换键'; + + @override + String get nextcloudServerSettings => '服务器 Notes 设置'; + + @override + String get nextcloudNotesPath => '笔记文件夹路径'; + + @override + String get nextcloudFileSuffix => '新笔记的文件后缀'; + + @override + String get nextcloudServerSettingsExplanation => + '这些服务器 Notes 设置会影响其他客户端。更改路径不会移动文件;更改后缀不会重命名现有笔记。'; + + @override + String get nextcloudSettingsSaved => '服务器设置已保存'; + + @override + String get nextcloudSettingsNormalized => '已保存服务器规范化后的值'; + + @override + String get nextcloudPendingUpload => '附件等待上传'; + + @override + String nextcloudServerCheck(String time) { + return '上次服务器检查:$time'; + } + + @override + String get nextcloudSettingsLoading => '正在加载服务器设置'; + + @override + String get nextcloudSettingsSaving => '正在保存服务器设置'; + + @override + String get nextcloudPreviousLocalEdit => '先前的本地编辑'; + + @override + String get nextcloudUsePreviousEdit => '使用先前编辑'; + + @override + String get quickOpen => '快速打开'; + + @override + String get notesAll => '所有笔记'; + + @override + String get notesUncategorized => '未分类'; + + @override + String get notesRecovery => '恢复'; + + @override + String get notesRecentlyEdited => '最近编辑'; + + @override + String get notesOldestEdited => '最早编辑'; + + @override + String get notesTitleAscending => '标题升序'; + + @override + String get notesTitleDescending => '标题降序'; + + @override + String get notesMove => '移至分类'; + + @override + String get notesRemoveFavorite => '取消收藏'; + + @override + String get notesOffline => '设为离线可用'; + + @override + String get notesOfflineRemove => '移除离线保留要求'; + + @override + String get notesIndexing => '正在索引笔记…'; + + @override + String get notesSearchHelp => '词项使用“与”组合。可使用\"引号短语\"、title: 和 category:。'; + + @override + String get notesSourceLocation => '源文件位置'; + + @override + String get notesRecoveryHelp => '仅包括保留的本地笔记和本地历史。无法访问服务器回收站或历史。'; + + @override + String get notesImport => '导入笔记'; + + @override + String get notesExport => '导出笔记'; + + @override + String get notesImportDistinct => '导入为独立笔记'; + + @override + String get notesAvailableOffline => '离线可用'; + + @override + String get notesIncomplete => '不完整'; + + @override + String get notesSelected => '已选笔记'; + + @override + String get notesNoHistory => '没有保留的版本。历史可能已禁用、过期或清除。'; + + @override + String get notesTextAvailable => '文本已在本地可用'; + + @override + String get notesLocalVersion => '本地工作版本'; + + @override + String get notesAttachments => '附件'; + + @override + String get notesSkipped => '已跳过'; + + @override + String get notesFailed => '失败'; + + @override + String get notesCompleted => '已完成'; + + @override + String get notesImportNew => '开始新导入'; + + @override + String get notesImportResume => '继续中断的导入'; + + @override + String get notesImportAlreadyImported => '已在此操作中导入'; + + @override + String get references => '引用'; + + @override + String get referenceIncoming => '传入'; + + @override + String get referenceOutgoing => '传出'; + + @override + String get referenceChooseTarget => '选择链接目标'; + + @override + String get referenceWholeDocument => '整个文档'; + + @override + String get referenceSourceLocation => '源位置'; + + @override + String get referenceTargetLocation => '目标位置'; + + @override + String get referenceResolved => '已解析'; + + @override + String get referenceMissing => '缺失'; + + @override + String get referenceAmbiguous => '不明确'; + + @override + String get referenceUnavailable => '当前上下文中不可用'; + + @override + String get referenceUnsupported => '不支持'; + + @override + String get referenceNoPortableId => '此目标没有经过验证的可移植笔记网址。'; } diff --git a/lib/src/app/busymark_app.dart b/lib/src/app/busymark_app.dart index 59fcd1d2..8d67112d 100644 --- a/lib/src/app/busymark_app.dart +++ b/lib/src/app/busymark_app.dart @@ -1,3 +1,4 @@ +import '../references/reference_controller.dart'; import 'dart:async'; import 'package:file_selector/file_selector.dart'; @@ -27,6 +28,7 @@ import 'app_router.dart'; import 'app_locale.dart'; import 'app_settings.dart'; import 'command_palette.dart'; +import 'quick_open.dart'; import 'command_registry.dart'; import 'app_theme.dart'; import 'busymark_dialogs.dart'; @@ -51,6 +53,7 @@ final busyMarkCommandRegistryProvider = Provider(( BusyMarkCommandIds.search: const _OpenSearchIntent(), BusyMarkCommandIds.keyboardShortcuts: const _KeyboardShortcutsIntent(), BusyMarkCommandIds.commandPalette: const _CommandPaletteIntent(), + BusyMarkCommandIds.quickOpen: const _QuickOpenIntent(), BusyMarkCommandIds.syntaxReference: const _SyntaxReferenceIntent(), BusyMarkCommandIds.settings: const _SettingsIntent(), BusyMarkCommandIds.nextTab: const _NextTabIntent(), @@ -76,6 +79,8 @@ final busyMarkCommandRegistryProvider = Provider(( }; return BusyMarkCommandCatalog.create( executions: { + BusyMarkCommandIds.references: () => + ref.read(referencePanelRequestProvider.notifier).request(), for (final entry in commandIntents.entries) entry.key: () { final target = rootNavigatorKey.currentContext; @@ -85,6 +90,8 @@ final busyMarkCommandRegistryProvider = Provider(( }, }, enabled: { + BusyMarkCommandIds.quickOpen: () => + ref.read(workspaceControllerProvider).workspace != null, BusyMarkCommandIds.save: () => ref.read(workspaceControllerProvider).workspace != null, BusyMarkCommandIds.export: () => @@ -374,6 +381,13 @@ class BusyMarkApp extends ConsumerWidget { return null; }, ), + _QuickOpenIntent: CallbackAction<_QuickOpenIntent>( + onInvoke: (_) { + final target = rootNavigatorKey.currentContext; + if (target != null) unawaited(showQuickOpen(target, ref)); + return null; + }, + ), _OpenSearchIntent: CallbackAction<_OpenSearchIntent>( onInvoke: (intent) { if (ref.read(workspaceControllerProvider).workspace != @@ -911,11 +925,27 @@ class _BusyMarkWindowLifecycle extends ConsumerStatefulWidget { class _BusyMarkWindowLifecycleState extends ConsumerState<_BusyMarkWindowLifecycle> { late final WindowControlService _windowControlService; + late final AppLifecycleListener _notesLifecycle; + late final GoRouter _notesRouter; @override void initState() { super.initState(); _windowControlService = ref.read(windowControlServiceProvider); + _notesRouter = ref.read(appRouterProvider); + _notesRouter.routeInformationProvider.addListener(_notesRouteChanged); + _notesLifecycle = AppLifecycleListener( + onStateChange: (state) { + final controller = ref.read(workspaceControllerProvider.notifier); + controller.setNotesApplicationVisible( + state == AppLifecycleState.resumed || + state == AppLifecycleState.inactive, + ); + if (state == AppLifecycleState.resumed) { + controller.refreshNotesOnFocus(); + } + }, + ); WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) { return; @@ -927,6 +957,8 @@ class _BusyMarkWindowLifecycleState @override void dispose() { + _notesRouter.routeInformationProvider.removeListener(_notesRouteChanged); + _notesLifecycle.dispose(); _windowControlService.unregisterCloseHandler(); super.dispose(); } @@ -936,6 +968,17 @@ class _BusyMarkWindowLifecycleState return widget.child; } + void _notesRouteChanged() { + if (!mounted) return; + ref + .read(workspaceControllerProvider.notifier) + .setNotesWorkspaceActive( + _notesRouter.routeInformationProvider.value.uri.path.startsWith( + '/workspace', + ), + ); + } + Future _handleWindowClose() async { final context = rootNavigatorKey.currentContext ?? this.context; if (!context.mounted) { @@ -1207,3 +1250,7 @@ class _DocumentViewModeIntent extends Intent { final DocumentViewModePreference mode; } + +class _QuickOpenIntent extends Intent { + const _QuickOpenIntent(); +} diff --git a/lib/src/app/busymark_design.dart b/lib/src/app/busymark_design.dart index edfafcd2..4cc14af5 100644 --- a/lib/src/app/busymark_design.dart +++ b/lib/src/app/busymark_design.dart @@ -1209,6 +1209,7 @@ class BusyMarkHeaderPopupMenuButton extends StatefulWidget { required this.icon, required this.itemBuilder, required this.onSelected, + this.onCanceled, this.transparent = true, this.elevated = false, this.shortcut, @@ -1224,6 +1225,7 @@ class BusyMarkHeaderPopupMenuButton extends StatefulWidget { final FutureOr>> Function(BuildContext context) itemBuilder; final ValueChanged onSelected; + final VoidCallback? onCanceled; final bool transparent; /// Uses the theme's physical button elevation. @@ -1284,6 +1286,10 @@ class _BusyMarkHeaderPopupMenuButtonState return; } setState(() => _loading = true); + // A menu response belongs to the context in which it was opened. A + // rebuilt caller may now represent another buffer or element. + final onSelected = widget.onSelected; + final onCanceled = widget.onCanceled; try { final items = await widget.itemBuilder(context); if (!mounted || items.isEmpty) { @@ -1313,8 +1319,12 @@ class _BusyMarkHeaderPopupMenuButtonState }); } } - if (mounted && !session.dismissed && selection != null) { - widget.onSelected(selection); + if (mounted && !session.dismissed) { + if (selection != null) { + onSelected(selection); + } else { + onCanceled?.call(); + } } } finally { if (mounted) { diff --git a/lib/src/app/busymark_main_menu.dart b/lib/src/app/busymark_main_menu.dart index 61ca9be9..00b13ac8 100644 --- a/lib/src/app/busymark_main_menu.dart +++ b/lib/src/app/busymark_main_menu.dart @@ -15,6 +15,7 @@ enum BusyMarkMainMenuAction { settings, keyboardShortcuts, commandPalette, + quickOpen, syntaxReference, reportIssue, aboutBusyMark, @@ -74,6 +75,12 @@ class BusyMarkMainMenuButton extends ConsumerWidget { BusyMarkCommandIds.keyboardShortcuts, ).shortcut?.label, ), + BusyMarkPopupMenuItem( + value: BusyMarkMainMenuAction.quickOpen, + label: command(BusyMarkCommandIds.quickOpen).label(context), + icon: BusyMarkGlyphs.search, + shortcut: command(BusyMarkCommandIds.quickOpen).shortcut?.label, + ), BusyMarkPopupMenuItem( value: BusyMarkMainMenuAction.commandPalette, label: command(BusyMarkCommandIds.commandPalette).label(context), @@ -104,6 +111,7 @@ class BusyMarkMainMenuButton extends ConsumerWidget { BusyMarkMainMenuAction.settings => BusyMarkCommandIds.settings, BusyMarkMainMenuAction.keyboardShortcuts => BusyMarkCommandIds.keyboardShortcuts, + BusyMarkMainMenuAction.quickOpen => BusyMarkCommandIds.quickOpen, BusyMarkMainMenuAction.commandPalette => BusyMarkCommandIds.commandPalette, BusyMarkMainMenuAction.syntaxReference => diff --git a/lib/src/app/busymark_shortcuts.dart b/lib/src/app/busymark_shortcuts.dart index f95852b2..6409c3be 100644 --- a/lib/src/app/busymark_shortcuts.dart +++ b/lib/src/app/busymark_shortcuts.dart @@ -28,6 +28,7 @@ enum BusyMarkAppShortcutAction { search, keyboardShortcuts, commandPalette, + quickOpen, syntaxReference, settings, nextTab, @@ -48,6 +49,14 @@ abstract final class BusyMarkAppShortcuts { static const backLabel = 'Alt+Left'; static const searchLabel = 'Ctrl+F'; static const keyboardShortcutsLabel = 'Ctrl+Alt+K'; + static const quickOpenLabel = 'Ctrl+P'; + static const quickOpenGtkAccelerator = 'p'; + static const quickOpen = BusyMarkShortcutDefinition( + label: quickOpenLabel, + activator: SingleActivator(LogicalKeyboardKey.keyP, control: true), + gtkAccelerator: quickOpenGtkAccelerator, + ); + static const commandPaletteLabel = 'Ctrl+Shift+P'; static const syntaxReferenceLabel = 'Ctrl+Alt+M'; static const settingsLabel = 'Ctrl+Alt+S'; @@ -198,6 +207,7 @@ abstract final class BusyMarkAppShortcuts { BusyMarkAppShortcutAction.search: search, BusyMarkAppShortcutAction.keyboardShortcuts: keyboardShortcuts, BusyMarkAppShortcutAction.commandPalette: commandPalette, + BusyMarkAppShortcutAction.quickOpen: quickOpen, BusyMarkAppShortcutAction.syntaxReference: syntaxReference, BusyMarkAppShortcutAction.settings: settings, BusyMarkAppShortcutAction.nextTab: nextTab, diff --git a/lib/src/app/command_registry.dart b/lib/src/app/command_registry.dart index ffeae413..bb1df531 100644 --- a/lib/src/app/command_registry.dart +++ b/lib/src/app/command_registry.dart @@ -251,6 +251,7 @@ abstract final class BusyMarkCommandIds { static const back = 'navigation.back'; static const search = 'search.find'; static const keyboardShortcuts = 'help.keyboardShortcuts'; + static const quickOpen = 'workspace.quickOpen'; static const commandPalette = 'view.commandPalette'; static const syntaxReference = 'help.syntaxReference'; static const settings = 'application.settings'; @@ -276,6 +277,7 @@ abstract final class BusyMarkCommandIds { static const textEscape = 'text.escape'; static const editorRefineWithAi = 'editor.refineWithAi'; static const checkSpelling = 'spelling.check'; + static const references = 'sidebar.references'; static const sidebarFiles = 'sidebar.files'; static const sidebarToc = 'sidebar.toc'; static const sidebarOutline = 'sidebar.outline'; @@ -330,6 +332,12 @@ abstract final class BusyMarkCommandCatalog { } final commands = [ + command( + id: BusyMarkCommandIds.references, + label: (context) => context.l10n.references, + category: (context) => context.l10n.shortcutGroupSidebar, + scope: BusyMarkCommandScope.sidebar, + ), for (final action in BusyMarkAppShortcutAction.values) command( id: _appId(action), @@ -459,6 +467,7 @@ abstract final class BusyMarkCommandCatalog { BusyMarkAppShortcutAction.fullScreen => BusyMarkCommandIds.fullScreen, BusyMarkAppShortcutAction.back => BusyMarkCommandIds.back, BusyMarkAppShortcutAction.search => BusyMarkCommandIds.search, + BusyMarkAppShortcutAction.quickOpen => BusyMarkCommandIds.quickOpen, BusyMarkAppShortcutAction.keyboardShortcuts => BusyMarkCommandIds.keyboardShortcuts, BusyMarkAppShortcutAction.commandPalette => @@ -484,6 +493,7 @@ abstract final class BusyMarkCommandCatalog { BusyMarkAppShortcutAction.fullScreen => context.l10n.fullScreen, BusyMarkAppShortcutAction.back => context.l10n.back, BusyMarkAppShortcutAction.search => context.l10n.search, + BusyMarkAppShortcutAction.quickOpen => context.l10n.quickOpen, BusyMarkAppShortcutAction.keyboardShortcuts => context.l10n.keyboardShortcuts, BusyMarkAppShortcutAction.commandPalette => context.l10n.commandPalette, diff --git a/lib/src/app/quick_open.dart b/lib/src/app/quick_open.dart new file mode 100644 index 00000000..345e365d --- /dev/null +++ b/lib/src/app/quick_open.dart @@ -0,0 +1,245 @@ +import 'dart:isolate'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:path/path.dart' as p; + +import '../nextcloud_notes/application/nextcloud_connection.dart'; +import '../nextcloud_notes/application/notes_navigation.dart'; +import '../nextcloud_notes/domain/notes_search.dart'; +import '../search/workspace_search_scope.dart'; +import '../workspace/workspace_controller.dart'; +import 'busymark_design.dart'; +import 'busymark_dialogs.dart'; +import 'localization.dart'; + +class QuickOpenDocument { + const QuickOpenDocument({ + required this.title, + required this.path, + this.localId, + this.filePath, + }); + final String title, path; + final String? localId, filePath; + String get identity => localId ?? filePath!; +} + +List rankQuickOpen( + List documents, + String query, { + int limit = 100, +}) { + final normalized = normalizeNotesSearch(query.trim()); + final ranked = <({QuickOpenDocument document, String title, int rank})>[]; + for (final document in documents) { + final title = normalizeNotesSearch(document.title); + final rank = normalized.isEmpty || title == normalized + ? 0 + : title.startsWith(normalized) + ? 1 + : title.contains(normalized) + ? 2 + : normalizeNotesSearch(document.path).contains(normalized) + ? 3 + : 4; + if (rank < 4) ranked.add((document: document, title: title, rank: rank)); + } + ranked.sort((a, b) { + final rank = a.rank.compareTo(b.rank); + if (rank != 0) return rank; + final title = a.title.compareTo(b.title); + if (title != 0) return title; + final path = a.document.path.compareTo(b.document.path); + return path != 0 + ? path + : a.document.identity.compareTo(b.document.identity); + }); + return ranked.take(limit).map((r) => r.document).toList(); +} + +Future showQuickOpen(BuildContext context, WidgetRef ref) async { + final workspace = ref.read(workspaceControllerProvider).workspace; + if (workspace == null) return; + final previous = FocusManager.instance.primaryFocus; + final documents = []; + if (workspace.isRemote) { + final repository = await ref.read(nextcloudNotesRepositoryProvider.future); + documents.addAll( + repository.notes + .where( + (n) => + n.accountId == workspace.nextcloudAccountId && + !isNotesRecovery(n), + ) + .map( + (n) => QuickOpenDocument( + title: n.title, + path: n.category, + localId: n.localId, + ), + ), + ); + } else { + documents.addAll( + workspace.files + .where(isSearchableWorkspaceDocument) + .map( + (f) => QuickOpenDocument( + title: p.basenameWithoutExtension(f.relativePath), + path: f.relativePath, + filePath: f.absolutePath, + ), + ), + ); + } + if (!context.mounted) return; + final result = await showBusyMarkModalDialog( + context, + builder: (_) => QuickOpenDialog(documents: documents), + ); + if (!ref.context.mounted || + ref.read(workspaceControllerProvider).workspace?.id != workspace.id) { + return; + } + if (result == null) { + if (previous?.context?.mounted == true) previous!.requestFocus(); + return; + } + final controller = ref.read(workspaceControllerProvider.notifier); + if (result.localId != null) { + await controller.openNextcloudNote(result.localId!); + } else { + await controller.openActiveFile(result.filePath!); + } +} + +class QuickOpenDialog extends StatefulWidget { + const QuickOpenDialog({super.key, required this.documents}); + final List documents; + @override + State createState() => _QuickOpenDialogState(); +} + +class _QuickOpenDialogState extends State { + late final FocusNode _focus = FocusNode(onKeyEvent: _key); + final _scroll = ScrollController(); + List _results = []; + int _selected = 0, _generation = 0; + bool _loading = false; + @override + void initState() { + super.initState(); + _search(''); + } + + Future _search(String query) async { + final generation = ++_generation; + setState(() => _loading = true); + final documents = widget.documents; + final results = await _rankQuickOpenAsync(documents, query); + if (!mounted || generation != _generation) return; + setState(() { + _results = results; + _selected = 0; + _loading = false; + }); + if (_scroll.hasClients) _scroll.jumpTo(0); + } + + KeyEventResult _key(FocusNode node, KeyEvent event) { + if (event is! KeyDownEvent) return KeyEventResult.ignored; + if (event.logicalKey == LogicalKeyboardKey.escape) { + Navigator.pop(context); + return KeyEventResult.handled; + } + if (event.logicalKey == LogicalKeyboardKey.enter) { + _open(); + return KeyEventResult.handled; + } + if (event.logicalKey == LogicalKeyboardKey.arrowDown || + event.logicalKey == LogicalKeyboardKey.arrowUp) { + if (_results.isNotEmpty) { + setState( + () => _selected = + (_selected + + (event.logicalKey == LogicalKeyboardKey.arrowDown + ? 1 + : -1)) + .clamp(0, _results.length - 1), + ); + if (_scroll.hasClients) { + _scroll.animateTo( + (_selected * 64.0).clamp(0, _scroll.position.maxScrollExtent), + duration: const Duration(milliseconds: 100), + curve: Curves.easeOut, + ); + } + } + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + } + + void _open() { + if (!_loading && _results.isNotEmpty) { + Navigator.pop(context, _results[_selected]); + } + } + + @override + void dispose() { + _generation++; + _focus.dispose(); + _scroll.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => BusyMarkDialogShell( + title: context.l10n.quickOpen, + maxWidth: 640, + children: [ + TextField( + autofocus: true, + focusNode: _focus, + decoration: InputDecoration(labelText: context.l10n.quickOpen), + onChanged: _search, + ), + if (_loading) const LinearProgressIndicator(), + SizedBox( + height: 340, + child: _results.isEmpty + ? Center(child: Text(context.l10n.noResults)) + : ListView.builder( + controller: _scroll, + itemCount: _results.length, + itemExtent: 64, + itemBuilder: (context, index) { + final document = _results[index]; + return ListTile( + selected: index == _selected, + title: Text( + document.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + subtitle: Text( + document.path, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + onTap: () => Navigator.pop(context, document), + ); + }, + ), + ), + ], + ); +} + +Future> _rankQuickOpenAsync( + List documents, + String query, +) => Isolate.run(() => rankQuickOpen(documents, query)); diff --git a/lib/src/assets/document_media_context.dart b/lib/src/assets/document_media_context.dart index 5fbaa903..26fa0138 100644 --- a/lib/src/assets/document_media_context.dart +++ b/lib/src/assets/document_media_context.dart @@ -1,50 +1,6 @@ -import 'dart:convert'; -import 'dart:io'; - import 'package:flutter/widgets.dart'; - -Future loadDocumentMediaText( - DocumentMediaContext media, - String reference, { - int maximumBytes = 4 * 1024 * 1024, -}) async { - final path = await media.resolve(reference); - if (path == null) { - throw const FileSystemException('This note attachment is unavailable.'); - } - final file = File(path); - if (await file.length() > maximumBytes) { - throw const FileSystemException( - 'This note attachment exceeds the text limit.', - ); - } - final bytes = await file.readAsBytes(); - if (bytes.length > maximumBytes) { - throw const FileSystemException( - 'This note attachment exceeds the text limit.', - ); - } - return utf8.decode(bytes); -} - -/// Access to media belongs to the document provider, never to authored paths. -/// A remote context deliberately has no local-filesystem fallback. -class DocumentMediaContext { - static final unavailable = DocumentMediaContext( - identity: 'remote-unavailable', - resolve: (_) async => null, - resolveCached: (_) => null, - ); - const DocumentMediaContext({ - required this.identity, - required this.resolve, - required this.resolveCached, - }); - - final String identity; - final Future Function(String reference) resolve; - final String? Function(String reference) resolveCached; -} +import 'document_media_resolver.dart'; +export 'document_media_resolver.dart'; class DocumentReadOnlyScope extends InheritedWidget { const DocumentReadOnlyScope({ diff --git a/lib/src/assets/document_media_resolver.dart b/lib/src/assets/document_media_resolver.dart new file mode 100644 index 00000000..58f78218 --- /dev/null +++ b/lib/src/assets/document_media_resolver.dart @@ -0,0 +1,45 @@ +import 'dart:convert'; +import 'dart:io'; + +Future loadDocumentMediaText( + DocumentMediaContext media, + String reference, { + int maximumBytes = 4 * 1024 * 1024, +}) async { + final path = await media.resolve(reference); + if (path == null) { + throw const FileSystemException('This note attachment is unavailable.'); + } + final file = File(path); + if (await file.length() > maximumBytes) { + throw const FileSystemException( + 'This note attachment exceeds the text limit.', + ); + } + final bytes = await file.readAsBytes(); + if (bytes.length > maximumBytes) { + throw const FileSystemException( + 'This note attachment exceeds the text limit.', + ); + } + return utf8.decode(bytes); +} + +/// Access to media belongs to the document provider, never to authored paths. +/// A remote context deliberately has no local-filesystem fallback. +class DocumentMediaContext { + static final unavailable = DocumentMediaContext( + identity: 'remote-unavailable', + resolve: (_) async => null, + resolveCached: (_) => null, + ); + const DocumentMediaContext({ + required this.identity, + required this.resolve, + required this.resolveCached, + }); + + final String identity; + final Future Function(String reference) resolve; + final String? Function(String reference) resolveCached; +} diff --git a/lib/src/core/linux_file_identity.dart b/lib/src/core/linux_file_identity.dart new file mode 100644 index 00000000..7083948f --- /dev/null +++ b/lib/src/core/linux_file_identity.dart @@ -0,0 +1,55 @@ +import 'dart:ffi'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:ffi/ffi.dart'; + +typedef _StatxNative = + Int32 Function(Int32, Pointer, Int32, Uint32, Pointer); +typedef _StatxDart = int Function(int, Pointer, int, int, Pointer); + +/// Durable identity across rename, including birth time when supported. The +/// fixed 256-byte Linux statx ABI comes from linux/uapi/linux/stat.h; unlike +/// struct stat, its offsets do not vary between supported Linux architectures. +final class LinuxFileIdentity { + LinuxFileIdentity._(); + static final _StatxDart? _statx = _load(); + static _StatxDart? _load() { + if (!Platform.isLinux) return null; + try { + return DynamicLibrary.open( + 'libc.so.6', + ).lookupFunction<_StatxNative, _StatxDart>('statx'); + } on ArgumentError { + return null; + } + } + + /// Unsupported identity inspection returns null. Recovery must fail closed + /// rather than infer identity from a pathname or matching document bytes. + static String? read(String path) { + final statx = _statx; + if (statx == null) return null; + final nativePath = path.toNativeUtf8(); + final buffer = calloc(256); + try { + // AT_FDCWD, AT_SYMLINK_NOFOLLOW, STATX_BASIC_STATS | STATX_BTIME. + if (statx(-100, nativePath, 0x100, 0xfff, buffer) != 0) { + throw FileSystemException('Cannot inspect filesystem identity', path); + } + final data = ByteData.sublistView(buffer.asTypedList(256)); + final mask = data.getUint32(0, Endian.host); + if (mask & 0x100 == 0) return null; // STATX_INO must be real. + final major = data.getUint32(0x88, Endian.host); + final minor = data.getUint32(0x8c, Endian.host); + final inode = data.getUint64(0x20, Endian.host); + final birth = mask & 0x800 == 0 + ? '' + : ':${data.getInt64(0x50, Endian.host)}:${data.getUint32(0x58, Endian.host)}'; + return '$major:$minor:$inode$birth'; + } finally { + calloc.free(buffer); + malloc.free(nativePath); + } + } +} diff --git a/lib/src/editor/inline_semantics.dart b/lib/src/editor/inline_semantics.dart index 4d471afb..5ffb1fa3 100644 --- a/lib/src/editor/inline_semantics.dart +++ b/lib/src/editor/inline_semantics.dart @@ -11,6 +11,8 @@ bool busyMarkSameInlineSemantics(BusyInline left, BusyInline right) => ); bool busyMarkIsInheritedInlineContext(BusyInlineKind kind) => + busyMarkIsSemanticInline(kind) || + kind == BusyInlineKind.writersideVariable || kind == BusyInlineKind.strong || kind == BusyInlineKind.emphasis || kind == BusyInlineKind.underline || diff --git a/lib/src/editor/link_destination.dart b/lib/src/editor/link_destination.dart new file mode 100644 index 00000000..29346c83 --- /dev/null +++ b/lib/src/editor/link_destination.dart @@ -0,0 +1,6 @@ +/// An ordinary link destination with optional Writerside semantic context. +class LinkDestination { + const LinkDestination(this.href, {this.origin}); + final String href; + final String? origin; +} diff --git a/lib/src/editor/source/source_commands.dart b/lib/src/editor/source/source_commands.dart index ba2f4429..1821b038 100644 --- a/lib/src/editor/source/source_commands.dart +++ b/lib/src/editor/source/source_commands.dart @@ -364,18 +364,23 @@ abstract final class SourceCommands { static TextEditingValue insertLink( TextEditingValue value, { required String labelPlaceholder, + String destination = 'url', + String? origin, }) { final selection = _normalizedSelection(value); final selected = selection.textInside(value.text); final label = selected.isEmpty ? labelPlaceholder : selected; - final replacement = '[$label](url)'; + final attrs = origin == null + ? '' + : '{origin="${origin.replaceAll('&', '&').replaceAll('"', '"')}"}'; + final replacement = '[$label]($destination)$attrs'; final urlStart = selection.start + label.length + 3; return _replaceSelection( value, replacement, selection: TextSelection( baseOffset: urlStart, - extentOffset: urlStart + 3, + extentOffset: urlStart + destination.length, ), ); } diff --git a/lib/src/editor/source/source_editor.dart b/lib/src/editor/source/source_editor.dart index d8034b65..5f1141af 100644 --- a/lib/src/editor/source/source_editor.dart +++ b/lib/src/editor/source/source_editor.dart @@ -1,3 +1,5 @@ +import '../link_destination.dart'; +import '../wysiwyg/writerside_editing_adapter.dart'; import '../../assets/document_media_context.dart'; import 'dart:async'; import 'dart:io'; @@ -112,6 +114,7 @@ class BusyMarkSourceEditor extends StatefulWidget { this.documentFormat, this.markdownMode, this.onAssetSaveRequired, + this.onChooseLink, this.spellingAnnotations = const [], this.onCheckSpelling, this.readSpellingMenuItems, @@ -156,6 +159,7 @@ class BusyMarkSourceEditor extends StatefulWidget { final SourceDocumentFormat? documentFormat; final MarkdownMode? markdownMode; final VoidCallback? onAssetSaveRequired; + final Future Function()? onChooseLink; final List spellingAnnotations; final VoidCallback? onCheckSpelling; final BusyMarkEditorSpellingMenuReader? readSpellingMenuItems; @@ -2612,6 +2616,10 @@ class BusyMarkSourceEditorState extends State { } void _applyInlineCommand(SourceInlineCommand command) { + if (command == SourceInlineCommand.link && widget.onChooseLink != null) { + unawaited(_chooseLink()); + return; + } _applyFullEditingValue( SourceCommands.applyInlineCommand( _fullEditingValue(), @@ -2623,6 +2631,53 @@ class BusyMarkSourceEditorState extends State { ); } + Future _chooseLink() async { + if (DocumentReadOnlyScope.of(context)) return; + final target = _fullEditingValue(); + final id = widget.documentId; + final revision = widget.editRevision; + final path = widget.filePath; + final destination = await widget.onChooseLink!(); + if (!mounted || + widget.documentId != id || + widget.filePath != path || + widget.editRevision != revision || + _fullEditingValue() != target || + destination == null || + DocumentReadOnlyScope.of(context) || + destination.href.trim().isEmpty) { + return; + } + if (_documentFormat == SourceDocumentFormat.writersideXmlTopic) { + final result = const WritersideEditingAdapter().insertSourceLink( + filePath: path ?? '', + source: target.text, + start: target.selection.start, + end: target.selection.end, + destination: destination.href.trim(), + origin: destination.origin, + placeholder: context.l10n.editorPlaceholderText, + ); + if (result != null) { + _applyFullEditingValue( + TextEditingValue( + text: result, + selection: TextSelection.collapsed(offset: target.selection.start), + ), + ); + } + return; + } + _applyFullEditingValue( + SourceCommands.insertLink( + target, + labelPlaceholder: context.l10n.editorPlaceholderText, + destination: destination.href.trim(), + origin: destination.origin, + ), + ); + } + void _applyBlockCommand(SourceBlockCommand command) { _applyFullEditingValue( SourceCommands.applyBlockCommand(_fullEditingValue(), command), diff --git a/lib/src/editor/wysiwyg/writerside_editing_adapter.dart b/lib/src/editor/wysiwyg/writerside_editing_adapter.dart new file mode 100644 index 00000000..107cb14c --- /dev/null +++ b/lib/src/editor/wysiwyg/writerside_editing_adapter.dart @@ -0,0 +1,857 @@ +import 'dart:convert'; +import 'package:xml/xml.dart'; +import '../../core/source_span.dart'; +import '../../markdown/busymark_document.dart'; +import '../../markdown/busymark_markdown_serializer.dart'; +import '../../markdown/markdown_model.dart'; +import '../../writerside/writerside_document.dart'; +import '../../writerside/writerside_document_parser.dart'; +import '../../writerside/writerside_document_serializer.dart'; +import '../../writerside/writerside_schema.dart'; + +/// An unresolved editing projection with bindings back to the authored tree. +/// Unlike the preview renderer, this never substitutes references or invents +/// titles. Unsupported nodes stay atomic and keep their original source. +class WritersideEditingAdapter { + const WritersideEditingAdapter(); + + /// Source authoring shares the semantic XML serializer and namespace rules. + String? insertSourceLink({ + required String filePath, + required String source, + required int start, + required int end, + required String destination, + required String placeholder, + String? origin, + }) { + final document = const WritersideDocumentParser().parseXml( + filePath: filePath, + source: source, + ); + if (!document.isWellFormed || + start < 0 || + end < start || + end > source.length) { + return null; + } + final parent = document + .walk() + .whereType() + .where((n) => n.span.startOffset < start && n.span.endOffset > end) + .lastOrNull; + if (parent == null || + !{ + 'p', + 'title', + 'b', + 'i', + 'em', + 'strong', + 'u', + 's', + 'td', + 'li', + }.contains(parent.name)) { + return null; + } + final openingEnd = source.indexOf('>', parent.span.startOffset); + final closingStart = source.lastIndexOf(' closingStart) return null; + final prefix = parent.qualifiedName.contains(':') + ? '${parent.qualifiedName.split(':').first}:' + : ''; + final inner = source.substring(start, end); + final link = elementNode( + 'a', + {'href': destination, if (origin != null) 'origin': origin}, + [ + if (inner.isEmpty) + ...inlineNodes( + BusyInline(kind: BusyInlineKind.text, text: placeholder), + filePath, + ) + else + rawNode(inner, filePath), + ], + filePath, + namespacePrefix: prefix, + ); + final fragment = const WritersideDocumentSerializer().serialize( + WritersideDocument( + filePath: filePath, + source: '', + format: WritersideDocumentFormat.xmlTopic, + nodes: [link], + ), + ); + final candidate = source.replaceRange(start, end, fragment); + final checked = const WritersideDocumentParser().parseXml( + filePath: filePath, + source: candidate, + ); + if (!checked.isWellFormed) return null; + final authored = checked.elements + .where((e) => e.name == 'a' && e.span.startOffset == start) + .firstOrNull; + return authored?.attributes['href'] == destination ? candidate : null; + } + + static const containers = { + 'topic', + 'chapter', + 'procedure', + 'step', + 'tabs', + 'tab', + 'deflist', + 'def', + 'tldr', + 'list', + 'li', + 'tip', + 'note', + 'warning', + 'quote', + }; + static const titleElements = {'topic', 'chapter', 'procedure', 'tab', 'def'}; + static const referenceLeaves = {'img', 'video', 'include', 'show-structure'}; + + BusyDocument? parseXml({required String filePath, required String source}) { + final authored = const WritersideDocumentParser().parseXml( + filePath: filePath, + source: source, + ); + if (!authored.isWellFormed || + authored.rootElement?.name != 'topic' || + authored.nodes.whereType().length != 1) { + return null; + } + var index = 0; + return BusyDocument( + filePath: filePath, + mode: MarkdownMode.writersideMarkdown, + source: source, + authoredXml: authored, + title: authored.rootElement?.attributes['title'], + blocks: [ + for (final node in authored.nodes) + blockFromNode(node, () => 'xml-${index++}'), + ], + ); + } + + BusyBlock? parseMarkdownElement(String source, String Function() nextId) { + final doc = const WritersideDocumentParser().parseMarkdown( + filePath: '', + source: source, + markdown: BusyDocument( + filePath: '', + mode: MarkdownMode.writersideMarkdown, + blocks: [ + BusyBlock( + id: 'semantic', + kind: BusyBlockKind.writersideRawXml, + rawSource: source, + sourceSpan: SourceSpan.entireFile('', source), + ), + ], + ), + ); + final element = doc.rootElement; + if (element == null || + (!containers.contains(element.name) && + !{'include', 'video', 'show-structure'}.contains(element.name)) || + element.name == 'topic') { + return null; + } + return blockFromNode(element, nextId, markdown: true); + } + + BusyBlock blockFromNode( + WritersideDocumentNode node, + String Function() nextId, { + bool markdown = false, + int level = 1, + String? listType, + }) { + final id = nextId(); + if (node is WritersideMarkdownBlockNode) { + BusyBlock reidentify(BusyBlock block) => block.copyWith( + id: nextId(), + children: block.children.map(reidentify).toList(), + ); + return reidentify(node.block); + } + final binding = '${node.span.startOffset}:${node.span.endOffset}'; + if (node is! WritersideElementNode) { + if (node is WritersideTextNode && node.text.trim().isNotEmpty) { + return BusyBlock( + id: id, + kind: BusyBlockKind.paragraph, + inlines: textInlines(node.text), + rawSource: node.rawSource, + sourceSpan: node.span, + attributes: { + busyMarkXmlBindingAttribute: binding, + 'element': '#text', + }, + ); + } + return BusyBlock( + id: id, + kind: BusyBlockKind.writersideRawXml, + rawSource: node.rawSource, + sourceSpan: node.span, + preserveRaw: true, + isSourceOnly: true, + attributes: {busyMarkXmlBindingAttribute: binding}, + ); + } + final tag = node.name; + if (WritersideSchema.requiredAttributesFor(tag).any( + (a) => + !node.attributes.containsKey(a) || + a != 'title' && (node.attributes[a] ?? '').trim().isEmpty, + )) { + return BusyBlock( + id: id, + kind: BusyBlockKind.writersideRawXml, + rawSource: node.rawSource, + sourceSpan: node.span, + preserveRaw: true, + attributes: { + ...node.attributes, + 'element': tag, + busyMarkXmlBindingAttribute: binding, + }, + ); + } + final container = containers.contains(tag); + final editable = + container || + { + 'p', + 'code-block', + 'video', + 'include', + 'img', + 'show-structure', + }.contains(tag); + final title = titleElements.contains(tag); + final attributes = { + ...node.attributes, + 'element': tag, + if (node.qualifiedName != tag) + 'busymark-xml-element-prefix': node.qualifiedName.substring( + 0, + node.qualifiedName.length - tag.length, + ), + if (referenceLeaves.contains(tag) && node.children.isNotEmpty) + 'busymark-xml-leaf-content': node.children + .map((n) => n.rawSource) + .join(), + busyMarkXmlBindingAttribute: binding, + if (container) busyMarkWritersideContainerAttribute: 'true', + if (tag == 'topic' || tag == 'chapter') 'level': '$level', + if (tag == 'topic' || tag == 'chapter') + 'generatedId': node.attributes.containsKey('id') ? 'false' : 'true', + if (tag == 'code-block') ...{ + 'language': node.attributes['lang'] ?? '', + busyMarkWritersideCodeBlockSourceFormAttribute: + busyMarkWritersideCodeBlockElementSourceForm, + }, + if (tag == 'li') 'marker': listType == 'decimal' ? '1.' : '-', + if (busyAdmonitionStyleFromName(tag) != null) ...{ + 'style': tag, + busyMarkWritersideAdmonitionAttribute: 'true', + busyMarkWritersideAdmonitionSourceFormAttribute: 'element', + }, + if (node.attributes.keys.any( + (key) => + key.startsWith('busymark-') || _projectionAttributes.contains(key), + )) + 'busymark-xml-source-attributes': jsonEncode(node.attributes), + }; + final kind = switch (tag) { + 'topic' || 'chapter' => BusyBlockKind.heading, + 'p' => BusyBlockKind.paragraph, + 'code-block' => BusyBlockKind.codeBlock, + 'tip' || + 'note' || + 'warning' || + 'quote' => BusyBlockKind.writersideAdmonition, + 'tabs' || 'tab' => BusyBlockKind.writersideTabs, + 'procedure' => BusyBlockKind.writersideProcedure, + 'li' => + listType == 'decimal' + ? BusyBlockKind.orderedListItem + : BusyBlockKind.unorderedListItem, + 'video' => BusyBlockKind.video, + 'img' => BusyBlockKind.image, + _ => BusyBlockKind.writersideRawXml, + }; + return BusyBlock( + id: id, + kind: kind, + attributes: attributes, + rawSource: node.rawSource, + sourceSpan: node.span, + preserveRaw: + !editable || + (tag == 'p' && + inlinesFromNodes(node.children).any((i) => _protectedInline(i))), + isSourceOnly: tag == 'show-structure' && !markdown, + inlines: title + ? [ + BusyInline( + kind: BusyInlineKind.text, + text: node.attributes['title'] ?? '', + ), + ] + : tag == 'img' + ? [ + BusyInline( + kind: BusyInlineKind.image, + text: node.attributes['alt'] ?? '', + destination: node.attributes['src'], + attributes: node.attributes, + ), + ] + : container || !editable + ? const [] + : tag == 'code-block' + ? [BusyInline(kind: BusyInlineKind.text, text: node.plainText)] + : inlinesFromNodes(node.children), + children: container + ? [ + for (final child in node.children) + blockFromNode( + child, + nextId, + markdown: markdown, + level: tag == 'topic' || tag == 'chapter' ? level + 1 : level, + listType: tag == 'list' ? node.attributes['type'] : listType, + ), + ] + : const [], + ); + } + + List textInlines(String text) { + final result = []; + var end = 0; + for (final match in RegExp(r'%([\w.-]+)%').allMatches(text)) { + if (match.start > end) { + result.add( + BusyInline( + kind: BusyInlineKind.text, + text: text.substring(end, match.start), + ), + ); + } + result.add( + BusyInline( + kind: BusyInlineKind.writersideVariable, + text: match.group(1)!, + attributes: {'reference': match.group(1)!}, + ), + ); + end = match.end; + } + if (end < text.length) { + result.add( + BusyInline(kind: BusyInlineKind.text, text: text.substring(end)), + ); + } + return result; + } + + List inlinesFromNodes(List nodes) => [ + for (final node in nodes) + if (node is WritersideTextNode) + ...textInlines(node.text) + else if (node is WritersideRawNode) + BusyInline( + kind: BusyInlineKind.html, + text: '', + attributes: {'xml-raw': node.rawSource}, + ) + else if (node is WritersideElementNode) + _inline(node), + ]; + + BusyInline _inline(WritersideElementNode node) { + final kind = switch (node.name) { + 'control' => BusyInlineKind.writersideControl, + 'path' => BusyInlineKind.writersidePath, + 'ui-path' => BusyInlineKind.writersideUiPath, + 'shortcut' => BusyInlineKind.writersideShortcut, + 'b' || 'strong' => BusyInlineKind.strong, + 'emphasis' || 'i' => BusyInlineKind.emphasis, + 'code' => BusyInlineKind.code, + 'u' => BusyInlineKind.underline, + 's' => BusyInlineKind.strikethrough, + 'br' => BusyInlineKind.hardBreak, + 'a' => BusyInlineKind.link, + 'img' => BusyInlineKind.image, + _ => BusyInlineKind.html, + }; + final inline = BusyInline( + kind: kind, + text: kind == BusyInlineKind.image + ? node.attributes['alt'] ?? '' + : kind == BusyInlineKind.hardBreak + ? '\n' + : node.plainText, + destination: node.attributes['href'] ?? node.attributes['src'], + children: kind == BusyInlineKind.html + ? const [] + : inlinesFromNodes(node.children), + attributes: { + ...node.attributes, + 'xml-name': node.qualifiedName, + if (kind == BusyInlineKind.html) 'xml-raw': node.rawSource, + }, + ); + return inline.copyWith( + attributes: { + ...inline.attributes, + 'xml-original-source': node.rawSource, + 'xml-original-signature': _inlineSignature(inline), + }, + ); + } + + static bool _protectedInline(BusyInline inline) => + (inline.attributes.containsKey('xml-raw') && + inline.plainText.isNotEmpty) || + inline.children.any(_protectedInline); + + static String _inlineSignature(BusyInline inline) { + final keys = + inline.attributes.keys + .where((k) => !k.startsWith('xml-') || k == 'xml-name') + .toList() + ..sort(); + return jsonEncode([ + inline.kind.name, + inline.text, + inline.destination, + {for (final k in keys) k: inline.attributes[k]}, + [for (final child in inline.children) _inlineSignature(child)], + ]); + } + + String serializeXml(BusyDocument document) { + final authored = document.authoredXml!; + final bindings = { + for (final node in authored.walk()) + '${node.span.startOffset}:${node.span.endOffset}': node, + }; + WritersideDocumentNode build(BusyBlock block, String namespacePrefix) { + final original = bindings[block.attributes[busyMarkXmlBindingAttribute]]; + if (block.preserveRaw || (!hasDirtyContent(block) && original != null)) { + return original ?? rawNode(block.rawSource ?? '', document.filePath); + } + final tag = block.attributes['element']; + if (tag == '#text') { + return WritersideTextNode( + text: block.plainText, + span: original?.span ?? SourceSpan.entireFile(document.filePath, ''), + rawSource: '', + isModified: true, + ); + } + final name = tagForBlock(block); + // New descendants belong to their actual parent's namespace. Its + // qualified prefix is already bound by the preserved declarations, + // including local aliases, default namespaces and prefix rebindings. + final childPrefix = original is WritersideElementNode + ? _namespacePrefix(original.qualifiedName) + : namespacePrefix; + final attributes = sourceAttributes(block); + if (titleElements.contains(name)) attributes['title'] = block.plainText; + if (name == 'code-block') { + attributes['lang'] = block.attributes['language'] ?? ''; + } + if (name == 'img') { + final image = block.inlines + .where((i) => i.kind == BusyInlineKind.image) + .firstOrNull; + if (image != null) { + attributes['src'] = image.destination ?? attributes['src'] ?? ''; + attributes['alt'] = image.text; + } + } + final leafContent = block.attributes['busymark-xml-leaf-content']; + final children = referenceLeaves.contains(name) + ? original is WritersideElementNode + ? original.children + : leafContent != null + ? [rawNode(leafContent, document.filePath)] + : const [] + : busyMarkIsWritersideContainer(block) || + containers.contains(name) && block.children.isNotEmpty + ? block.children.map((child) => build(child, childPrefix)).toList() + : [ + for (final inline in block.inlines) + ...inlineNodes( + inline, + document.filePath, + namespacePrefix: childPrefix, + ), + ]; + return elementNode( + name, + attributes, + children, + document.filePath, + original: original is WritersideElementNode ? original : null, + namespacePrefix: namespacePrefix, + ); + } + + final next = authored.copyWith( + nodes: document.blocks.map((block) => build(block, '')).toList(), + ); + return const WritersideDocumentSerializer().serialize(next); + } + + /// Reparse the committed source to refresh every span and raw subtree, while + /// retaining the live IDs used by selection, outline and transaction targets. + BusyDocument rebaseXml(BusyDocument document, String source) { + final parsed = parseXml(filePath: document.filePath, source: source); + if (parsed == null) { + throw const FormatException('Invalid authored XML edit'); + } + List retain(List old, List fresh) { + final oldVisible = old.where((b) => !b.isSourceOnly).toList(); + var index = 0; + return [ + for (final block in fresh) + if (block.isSourceOnly) + block.copyWith(id: 'xml-raw-${block.id}') + else if (index < oldVisible.length) + (() { + final previous = oldVisible[index++]; + return block.copyWith( + id: previous.id, + children: retain(previous.children, block.children), + ); + })() + else + block, + ]; + } + + return parsed.copyWith(blocks: retain(document.blocks, parsed.blocks)); + } + + /// Maps an editor boundary to its authored XML text, including entities and + /// title attributes. Comments and markup never participate in text matching. + int? xmlTextSourceOffset( + BusyDocument document, + BusyBlock block, + int textOffset, { + required bool endBoundary, + }) { + final authored = document.authoredXml; + final binding = block.attributes[busyMarkXmlBindingAttribute]; + if (authored == null || binding == null) return null; + final node = authored + .walk() + .where((n) => '${n.span.startOffset}:${n.span.endOffset}' == binding) + .firstOrNull; + if (node == null) return null; + final ranges = <({int start, int end})>[]; + void append( + String raw, + int start, { + bool literal = false, + bool variables = false, + }) { + for (var i = 0; i < raw.length;) { + if (variables && raw[i] == '%') { + final match = RegExp(r'^%[\w.-]+%').firstMatch(raw.substring(i)); + if (match != null) { + for (var j = 1; j < match.end - 1; j++) { + ranges.add((start: start + i + j, end: start + i + j + 1)); + } + i += match.end; + continue; + } + } + if (!literal && raw[i] == '&') { + final end = raw.indexOf(';', i + 1); + if (end >= 0) { + final decoded = XmlDocumentFragment.parse( + raw.substring(i, end + 1), + ).innerText; + for (var j = 0; j < decoded.length; j++) { + ranges.add((start: start + i, end: start + end + 1)); + } + i = end + 1; + continue; + } + } + ranges.add((start: start + i, end: start + i + 1)); + i++; + } + } + + if (node is WritersideElementNode && titleElements.contains(node.name)) { + final span = node.attributeSpans['title']; + if (span == null) return node.span.startOffset; + append( + authored.source.substring(span.startOffset, span.endOffset), + span.startOffset, + ); + } else { + for (final text in node.walk().whereType()) { + final cdata = text.rawSource.startsWith(' 0 + ? ranges[offset - 1].end + : ranges[offset].start; + } + + static bool hasDirtyContent(BusyBlock block) => + block.dirty || block.children.any(hasDirtyContent); + static const _projectionAttributes = { + 'element', + 'level', + 'language', + 'marker', + 'ordered', + 'style', + 'generatedId', + 'sourceFormat', + busyMarkWritersideAdmonitionAttribute, + busyMarkWritersideAdmonitionSourceFormAttribute, + busyMarkWritersideCodeBlockSourceFormAttribute, + busyMarkPreserveTextWhitespaceAttribute, + busyMarkTransientTrailingParagraphAttribute, + busyMarkPreserveEmptyParagraphAttribute, + }; + static bool _isProjectionAttribute(String key, BusyBlock block) => + key.startsWith('busymark-') || + _projectionAttributes.contains(key) && + (key != 'style' || + block.kind == BusyBlockKind.writersideAdmonition || + block.kind == BusyBlockKind.blockquote); + static Map sourceAttributes(BusyBlock block) => { + // Some authored attributes share names with the existing Markdown editing + // metadata. Keep their original values independently of the projection. + if (block.attributes['busymark-xml-source-attributes'] case final source?) + for (final entry in (jsonDecode(source) as Map).entries) + if (_isProjectionAttribute(entry.key, block)) + entry.key: entry.value as String, + for (final entry in block.attributes.entries) + if (!_isProjectionAttribute(entry.key, block)) entry.key: entry.value, + }; + static String tagForBlock(BusyBlock block) => switch (block.kind) { + BusyBlockKind.paragraph => 'p', + BusyBlockKind.heading => + block.attributes['element'] == 'topic' ? 'topic' : 'chapter', + BusyBlockKind.codeBlock => 'code-block', + BusyBlockKind.image => 'img', + BusyBlockKind.video => 'video', + BusyBlockKind.unorderedListItem || + BusyBlockKind.orderedListItem || + BusyBlockKind.taskListItem => 'li', + BusyBlockKind.blockquote || + BusyBlockKind.writersideAdmonition => block.attributes['style'] ?? 'quote', + _ => block.attributes['element'] ?? 'p', + }; + + static WritersideRawNode rawNode(String source, String path) => + WritersideRawNode( + span: SourceSpan.entireFile(path, source), + rawSource: source, + ); + static String _namespacePrefix(String qualifiedName) => + qualifiedName.contains(':') ? '${qualifiedName.split(':').first}:' : ''; + static WritersideElementNode elementNode( + String name, + Map attributes, + List children, + String path, { + WritersideElementNode? original, + String namespacePrefix = '', + String? qualifiedName, + }) { + final prefix = original?.qualifiedName.contains(':') == true + ? '${original!.qualifiedName.split(':').first}:' + : original == null + ? namespacePrefix + : ''; + return WritersideGenericElementNode( + name: name, + qualifiedName: qualifiedName ?? '$prefix$name', + attributes: attributes, + children: children, + schemaKnown: WritersideSchema.isKnownElement(name), + qualifiedAttributes: original?.qualifiedAttributes ?? const [], + attributeSpans: original?.attributeSpans ?? const {}, + span: original?.span ?? SourceSpan.entireFile(path, ''), + rawSource: original?.rawSource ?? (children.isEmpty ? '<$name/>' : ''), + isModified: true, + ); + } + + static List inlineNodes( + BusyInline inline, + String path, { + String namespacePrefix = '', + }) { + final source = inline.attributes['xml-original-source']; + if (source != null && + inline.attributes['xml-original-signature'] == + _inlineSignature(inline)) { + return [rawNode(source, path)]; + } + if (inline.attributes['xml-raw'] case final raw?) { + return [rawNode(raw, path)]; + } + if (inline.kind == BusyInlineKind.hardBreak) { + return [ + elementNode( + 'br', + const {}, + const [], + path, + namespacePrefix: namespacePrefix, + ), + ]; + } + if (inline.kind == BusyInlineKind.text || + inline.kind == BusyInlineKind.softBreak || + inline.kind == BusyInlineKind.writersideVariable) { + final text = inline.kind == BusyInlineKind.writersideVariable + ? '%${inline.attributes['reference'] ?? inline.text}%' + : inline.text; + return [ + WritersideTextNode( + text: text, + span: SourceSpan.entireFile(path, ''), + rawSource: '', + isModified: true, + ), + ]; + } + final name = + busyMarkSemanticInlineTag(inline.kind) ?? + switch (inline.kind) { + BusyInlineKind.strong => 'b', + BusyInlineKind.emphasis => 'emphasis', + BusyInlineKind.underline => 'u', + BusyInlineKind.strikethrough => 's', + BusyInlineKind.code => 'code', + BusyInlineKind.link => 'a', + BusyInlineKind.image => 'img', + _ => inline.attributes['xml-name'] ?? 'emphasis', + }; + final attrs = { + for (final e in inline.attributes.entries) + if (!e.key.startsWith('xml-') && e.key != 'reference') e.key: e.value, + if (inline.kind == BusyInlineKind.link && inline.destination != null) + 'href': inline.destination!, + if (inline.kind == BusyInlineKind.image && inline.destination != null) + 'src': inline.destination!, + if (inline.kind == BusyInlineKind.image) 'alt': inline.text, + }; + final qualifiedName = inline.attributes['xml-name']; + final childPrefix = qualifiedName != null + ? _namespacePrefix(qualifiedName) + : namespacePrefix; + return [ + elementNode( + name.split(':').last, + attrs, + inline.kind == BusyInlineKind.image + ? const [] + : inline.children.isEmpty + ? [ + WritersideTextNode( + text: inline.text, + span: SourceSpan.entireFile(path, ''), + rawSource: '', + isModified: true, + ), + ] + : [ + for (final child in inline.children) + ...inlineNodes(child, path, namespacePrefix: childPrefix), + ], + path, + namespacePrefix: namespacePrefix, + qualifiedName: inline.attributes['xml-name'], + ), + ]; + } + + String serializeMarkdownElement(BusyBlock block) { + if (!hasDirtyContent(block) && block.rawSource != null) { + return block.rawSource!; + } + final name = block.attributes['element'] ?? tagForBlock(block); + final qualifiedName = + '${block.attributes['busymark-xml-element-prefix'] ?? ''}$name'; + final attrs = sourceAttributes(block); + if (titleElements.contains(name)) attrs['title'] = block.plainText; + final attributes = attrs.entries + .map((e) => ' ${e.key}="${escape(e.value)}"') + .join(); + if (!busyMarkIsWritersideContainer(block)) { + final content = block.attributes['busymark-xml-leaf-content']; + return content == null + ? '<$qualifiedName$attributes/>' + : '<$qualifiedName$attributes>$content'; + } + final content = block.children + .map( + (child) => child.isSourceOnly + ? child.rawSource ?? '' + : busyMarkIsWritersideContainer(child) + ? serializeMarkdownElement(child) + : child.kind == BusyBlockKind.paragraph && + (name == 'tldr' || child.attributes['element'] == 'p') + ? const WritersideDocumentSerializer().serialize( + WritersideDocument( + filePath: '', + source: '', + format: WritersideDocumentFormat.xmlTopic, + nodes: [ + elementNode('p', sourceAttributes(child), [ + for (final inline in child.inlines) + ...inlineNodes(inline, ''), + ], ''), + ], + ), + ) + : const BusyMarkMarkdownSerializer().serializeBlock(child), + ) + .join('\n\n'); + return '<$qualifiedName$attributes>\n\n$content\n\n'; + } + + static String escape(String value) => value + .replaceAll('&', '&') + .replaceAll('"', '"') + .replaceAll('<', '<') + .replaceAll('>', '>'); +} diff --git a/lib/src/editor/wysiwyg/writerside_properties.dart b/lib/src/editor/wysiwyg/writerside_properties.dart new file mode 100644 index 00000000..231cdf53 --- /dev/null +++ b/lib/src/editor/wysiwyg/writerside_properties.dart @@ -0,0 +1,504 @@ +import 'package:flutter/material.dart'; + +import '../../app/busymark_design.dart'; +import '../../app/busymark_glyphs.dart'; +import '../../app/localization.dart'; +import '../../markdown/busymark_document.dart'; +import '../../writerside/writerside_schema.dart'; +import 'writerside_editing_adapter.dart'; +import 'wysiwyg_inline_controller.dart'; + +String writersideElementLabel(BuildContext context, BusyBlock block) => + switch (block.attributes['element']) { + 'topic' => context.l10n.topic, + 'chapter' => context.l10n.chapter, + 'procedure' => context.l10n.procedure, + 'step' => context.l10n.wsStep, + 'tabs' => context.l10n.tabs, + 'tab' => context.l10n.tab, + 'deflist' => context.l10n.wsDefinitionList, + 'def' => context.l10n.wsTerm, + 'tldr' => context.l10n.wsTldr, + 'include' => context.l10n.wsIncludeContent, + 'video' => context.l10n.video, + _ => switch (block.kind) { + BusyBlockKind.heading => + block.attributes['level'] == '1' + ? context.l10n.topic + : context.l10n.chapter, + BusyBlockKind.codeBlock => context.l10n.codeBlock, + BusyBlockKind.blockquote || + BusyBlockKind.writersideAdmonition => context.l10n.admonition, + _ => context.l10n.paragraph, + }, + }; + +/// Subordinate, non-modal authoring properties. Targets are supplied by the +/// editor, never inferred from whichever property entry happens to have focus. +class BusyMarkWritersideProperties extends StatelessWidget { + const BusyMarkWritersideProperties({ + super.key, + required this.document, + required this.path, + required this.target, + required this.topicSelected, + required this.onTargetSelected, + required this.onClose, + required this.onProperty, + required this.onHeadingText, + required this.onTopicProperty, + required this.onManageItem, + this.inlineTarget, + this.onInlineProperty, + this.videoDraft = false, + this.onInsertVideo, + }); + final BusyDocument document; + final List path; + final BusyBlock? target; + final bool topicSelected; + final ValueChanged onTargetSelected; + final VoidCallback onClose; + final bool Function(String, String?) onProperty; + final bool Function(String) onHeadingText; + final bool Function(String, String) onTopicProperty; + final void Function(String? itemId, int? direction, bool remove) onManageItem; + final BusyInlineStyleRange? inlineTarget; + final bool Function(String, String)? onInlineProperty; + final bool videoDraft; + final bool Function(String)? onInsertVideo; + + @override + Widget build(BuildContext context) { + final l = context.l10n; + final block = target; + final tag = block == null + ? '' + : block.kind == BusyBlockKind.heading && !document.isXmlTopic + ? block.attributes['level'] == '1' + ? 'topic' + : 'chapter' + : WritersideEditingAdapter.tagForBlock(block); + final attrs = block?.attributes ?? const {}; + Widget entry( + String attribute, + String label, { + String? value, + bool topic = false, + bool inline = false, + }) => _PropertyTextEntry( + key: ValueKey( + 'writerside-property-${topic ? 'topic' : block?.id}-$attribute', + ), + label: label, + value: value ?? attrs[attribute] ?? '', + apply: (value) => inline + ? onInlineProperty?.call(attribute, value) ?? false + : topic + ? onTopicProperty(attribute, value) + : onProperty(attribute, value), + ); + Widget stateRow() { + final value = attrs['default-state'] ?? 'collapsed'; + final values = WritersideSchema.valuesFor(tag, 'default-state').toList(); + return BusyMarkComboRow( + title: l.wsPublishedState, + values: values.contains(value) ? values : [value, ...values], + selected: value, + labelFor: (v) => switch (v) { + 'expanded' => l.wsExpanded, + 'collapsed' => l.wsCollapsed, + _ => v, + }, + onSelected: (v) => onProperty('default-state', v), + width: 120, + ); + } + + final ancestors = path + .where( + (b) => + busyMarkIsWritersideContainer(b) || + b.kind == BusyBlockKind.heading || + b.id == block?.id, + ) + .toList(); + final values = [ + '@topic', + ...{for (final b in ancestors) b.id}, + ]; + final selected = topicSelected ? '@topic' : block?.id ?? '@topic'; + if (!values.contains(selected)) values.add(selected); + final rows = []; + if (videoDraft) { + rows.add( + _PropertyTextEntry( + key: const ValueKey('writerside-video-source'), + label: l.source, + value: '', + apply: (v) => onInsertVideo?.call(v) ?? false, + ), + ); + } else if (topicSelected) { + final root = document.blocks + .where((b) => b.attributes['element'] == 'topic') + .firstOrNull; + BusyBlock? navigation; + for (final b in root?.children ?? document.blocks) { + if (b.attributes['element'] == 'show-structure') { + navigation = b; + break; + } + } + rows.add( + entry( + 'switcher-label', + l.wsSwitcherLabel, + value: + root?.attributes['switcher-label'] ?? + document.frontMatter['switcher-label'] ?? + '', + topic: true, + ), + ); + rows.add( + entry( + 'for', + l.wsNavigationElements, + value: navigation?.attributes['for'] ?? '', + topic: true, + ), + ); + rows.add( + entry( + 'depth', + l.wsNavigationDepth, + value: navigation?.attributes['depth'] ?? '', + topic: true, + ), + ); + } else if (block != null) { + if (block.kind == BusyBlockKind.heading) { + rows.add( + _PropertyTextEntry( + key: ValueKey('writerside-heading-title-${block.id}'), + label: tag == 'topic' ? l.topicTitle : l.wsTitle, + value: block.plainText, + apply: onHeadingText, + ), + ); + } else if (WritersideEditingAdapter.titleElements.contains(tag)) { + rows.add( + entry( + 'title', + tag == 'def' ? l.wsTerm : l.wsTitle, + value: block.plainText, + ), + ); + } + if (WritersideSchema.attributesFor(tag).contains('id')) { + rows.add(entry('id', l.wsIdentifier)); + } + if ({'chapter', 'code-block', 'procedure', 'deflist'}.contains(tag)) { + rows.add( + BusyMarkSwitchRow( + title: l.wsCollapsible, + value: busyMarkWritersideIsCollapsible(attrs), + onChanged: (v) => onProperty('collapsible', v ? 'true' : 'false'), + ), + ); + // A definition item owns its published initial state. List collapse + // is not an interchangeable property of the current paragraph/item. + if (tag != 'deflist') rows.add(stateRow()); + } + if (tag == 'def') rows.add(stateRow()); + if (tag == 'code-block') { + rows.add(entry('language', l.language)); + rows.add(entry('collapsed-title', l.wsCollapsedTitle)); + rows.add(entry('src', l.source)); + } + if (tag == 'deflist') { + final value = attrs['type'] ?? 'full'; + final allowed = WritersideSchema.valuesFor('deflist', 'type').toList(); + rows.add( + BusyMarkComboRow( + title: l.wsListLayout, + values: allowed.contains(value) ? allowed : [value, ...allowed], + selected: value, + labelFor: (v) => switch (v) { + 'full' => l.wsFullLayout, + 'medium' => l.wsMediumLayout, + 'narrow' => l.wsNarrowLayout, + _ => v, + }, + onSelected: (v) => onProperty('type', v), + width: 110, + ), + ); + } + if (tag == 'tabs') rows.add(entry('group', l.wsSyncGroup)); + if (tag == 'tab') rows.add(entry('group-key', l.wsTabKey)); + if (busyAdmonitionStyleFromName(tag) != null || + block.kind == BusyBlockKind.blockquote && + attrs[busyMarkWritersideAdmonitionAttribute] == 'true') { + final value = + busyAdmonitionStyleFromName(attrs['style'] ?? tag) ?? + BusyAdmonitionStyle.tip; + rows.add( + BusyMarkComboRow( + title: l.admonition, + values: BusyAdmonitionStyle.values, + selected: value, + labelFor: (v) => switch (v) { + BusyAdmonitionStyle.tip => l.tip, + BusyAdmonitionStyle.note => l.note, + BusyAdmonitionStyle.warning => l.warning, + BusyAdmonitionStyle.quote => l.quote, + }, + onSelected: (v) => onProperty('admonition-type', v.name), + width: 120, + ), + ); + } + if (tag == 'include') { + rows.add(entry('from', l.source)); + rows.add(entry('element-id', l.wsIdentifier)); + if (attrs.containsKey('origin')) { + rows.add(entry('origin', l.writerside)); + } + rows.add(entry('use-filter', l.wsFilter)); + } + if (tag == 'video') { + rows.add(entry('src', l.source)); + rows.add(entry('preview-src', l.videoPreview)); + } + if (WritersideSchema.attributesFor(tag).contains('switcher-key') && + tag != 'topic') { + rows.add(entry('switcher-key', l.wsSwitcherKey)); + } + if (inlineTarget case final inline?) { + if (inline.kind == BusyInlineKind.writersideVariable) { + rows.add( + entry( + 'reference', + l.wsVariableReference, + value: + inline.attributes['reference'] ?? + block.plainText.substring(inline.start, inline.end), + inline: true, + ), + ); + } else if (inline.kind == BusyInlineKind.writersideShortcut) { + rows.add( + entry( + 'key', + l.keyboardShortcuts, + value: inline.attributes['key'] ?? '', + inline: true, + ), + ); + rows.add( + entry( + 'from-keymap-of', + l.source, + value: inline.attributes['from-keymap-of'] ?? '', + inline: true, + ), + ); + rows.add( + entry( + 'force-layout', + l.keyboardLayout, + value: inline.attributes['force-layout'] ?? '', + inline: true, + ), + ); + } + } + if ({'tabs', 'procedure', 'deflist'}.contains(tag)) { + rows.add( + Align( + alignment: AlignmentDirectional.centerStart, + child: BusyMarkHeaderIconButton( + icon: BusyMarkGlyphs.add, + tooltip: l.wsAddItem, + onPressed: () => onManageItem(null, null, false), + ), + ), + ); + final itemTag = switch (tag) { + 'tabs' => 'tab', + 'procedure' => 'step', + _ => 'def', + }; + for (final item in block.children.where( + (b) => b.attributes['element'] == itemTag, + )) { + rows.add( + Row( + children: [ + Expanded( + child: Text( + item.plainText.isEmpty + ? writersideElementLabel(context, item) + : item.plainText, + overflow: TextOverflow.ellipsis, + ), + ), + BusyMarkHeaderIconButton( + icon: BusyMarkGlyphs.upArrow, + tooltip: l.moveSectionUp, + onPressed: () => onManageItem(item.id, -1, false), + ), + BusyMarkHeaderIconButton( + icon: BusyMarkGlyphs.downArrow, + tooltip: l.moveSectionDown, + onPressed: () => onManageItem(item.id, 1, false), + ), + BusyMarkHeaderIconButton( + icon: BusyMarkGlyphs.delete, + tooltip: l.removeAction, + onPressed: + block.children + .where((b) => b.attributes['element'] == itemTag) + .length > + 1 + ? () => onManageItem(item.id, null, true) + : null, + ), + ], + ), + ); + } + } + } + return DecoratedBox( + decoration: BoxDecoration(color: BusyMarkSurfaceColors.of(context).panel), + child: FocusTraversalGroup( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsetsDirectional.only(start: 12, end: 4), + child: Row( + children: [ + Expanded( + child: Text( + topicSelected + ? l.wsTopicProperties + : l.wsElementProperties, + style: busyMarkSectionHeaderStyle(context), + overflow: TextOverflow.ellipsis, + ), + ), + BusyMarkHeaderIconButton( + icon: BusyMarkGlyphs.windowClose, + tooltip: l.close, + onPressed: onClose, + ), + ], + ), + ), + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.all(8), + child: Column( + children: [ + BusyMarkGroupedList( + filled: true, + children: [ + BusyMarkComboRow( + title: videoDraft + ? l.video + : topicSelected + ? l.topic + : block == null + ? l.wsElementProperties + : writersideElementLabel(context, block), + values: values, + selected: selected, + labelFor: (id) => id == '@topic' + ? l.wsTopicProperties + : (() { + final b = path + .where((b) => b.id == id) + .firstOrNull; + return b == null + ? l.wsElementProperties + : '${writersideElementLabel(context, b)}${b.plainText.isEmpty ? '' : ' · ${b.plainText}'}'; + })(), + onSelected: onTargetSelected, + width: 160, + ), + ], + ), + if (rows.isNotEmpty) + BusyMarkGroupedList(filled: true, children: rows), + ], + ), + ), + ), + ], + ), + ), + ); + } +} + +class _PropertyTextEntry extends StatefulWidget { + const _PropertyTextEntry({ + super.key, + required this.label, + required this.value, + required this.apply, + }); + final String label, value; + final bool Function(String) apply; + @override + State<_PropertyTextEntry> createState() => _PropertyTextEntryState(); +} + +class _PropertyTextEntryState extends State<_PropertyTextEntry> { + late final TextEditingController _controller = TextEditingController( + text: widget.value, + ); + final _focus = FocusNode(); + bool _invalid = false; + @override + void didUpdateWidget(covariant _PropertyTextEntry old) { + super.didUpdateWidget(old); + if (!_focus.hasFocus && widget.value != old.value) { + _controller.text = widget.value; + } + } + + @override + void dispose() { + _controller.dispose(); + _focus.dispose(); + super.dispose(); + } + + void _apply() { + final accepted = + _controller.text == widget.value || widget.apply(_controller.text); + if (mounted) setState(() => _invalid = !accepted); + } + + @override + Widget build(BuildContext context) => BusyMarkGroupedTextEntry( + label: widget.label, + controller: _controller, + focusNode: _focus, + errorText: _invalid ? context.l10n.wsPropertyInvalid : null, + onSubmitted: (_) => _apply(), + onChanged: (_) { + if (_invalid) setState(() => _invalid = false); + }, + trailing: BusyMarkHeaderIconButton( + icon: BusyMarkGlyphs.check, + tooltip: context.l10n.apply, + onPressed: _apply, + ), + ); +} diff --git a/lib/src/editor/wysiwyg/wysiwyg_block_widgets.dart b/lib/src/editor/wysiwyg/wysiwyg_block_widgets.dart index 12055be3..8f46fe69 100644 --- a/lib/src/editor/wysiwyg/wysiwyg_block_widgets.dart +++ b/lib/src/editor/wysiwyg/wysiwyg_block_widgets.dart @@ -457,6 +457,20 @@ class BusyMarkWysiwygBlockField extends StatelessWidget { borderEffect: block.attributes['border-effect'] ?? 'none', ); } + if (!block.preserveRaw && block.attributes['element'] == 'include') { + final from = block.attributes['from'] ?? ''; + final elementId = block.attributes['element-id'] ?? ''; + return BusyMarkGroupedList( + children: [ + BusyMarkActionRow( + title: context.l10n.wsIncludeContent, + subtitle: elementId.isEmpty ? from : '$from#$elementId', + tooltip: context.l10n.wsElementProperties, + onTap: onFocused, + ), + ], + ); + } if (_isRenderedHtmlBlock) { return _RenderedHtmlBlockEditor( block: block, diff --git a/lib/src/editor/wysiwyg/wysiwyg_clipboard_fragment.dart b/lib/src/editor/wysiwyg/wysiwyg_clipboard_fragment.dart index 53e788f2..67384cc2 100644 --- a/lib/src/editor/wysiwyg/wysiwyg_clipboard_fragment.dart +++ b/lib/src/editor/wysiwyg/wysiwyg_clipboard_fragment.dart @@ -1,5 +1,7 @@ import 'dart:convert'; +import 'package:flutter/foundation.dart'; + import 'package:path/path.dart' as p; import '../../markdown/busymark_document.dart'; @@ -265,6 +267,14 @@ class WysiwygClipboardFragment { ); BusyBlock block(BusyBlock value) { final rebasedAttributes = attributes(value.attributes); + final rebasedInlines = [for (final child in value.inlines) inline(child)]; + bool referencesChanged(BusyInline before, BusyInline after) => + before.destination != after.destination || + !mapEquals(before.attributes, after.attributes) || + [ + for (var i = 0; i < before.children.length; i++) + referencesChanged(before.children[i], after.children[i]), + ].any((v) => v); final rawSource = value.kind == BusyBlockKind.video ? _rebaseVideoRawSource( value.rawSource, @@ -275,7 +285,7 @@ class WysiwygClipboardFragment { return BusyBlock( id: value.id, kind: value.kind, - inlines: [for (final child in value.inlines) inline(child)], + inlines: rebasedInlines, children: [for (final child in value.children) block(child)], attributes: rebasedAttributes, rawSource: rawSource, @@ -283,7 +293,13 @@ class WysiwygClipboardFragment { isSourceOnly: value.isSourceOnly, isGenerated: value.isGenerated, isSourceProtected: value.isSourceProtected, - dirty: true, + dirty: + value.dirty || + !mapEquals(value.attributes, rebasedAttributes) || + [ + for (var i = 0; i < value.inlines.length; i++) + referencesChanged(value.inlines[i], rebasedInlines[i]), + ].any((v) => v), ); } @@ -363,6 +379,7 @@ Map _encodeBlock(BusyBlock block) => { 'sourceOnly': block.isSourceOnly, 'generated': block.isGenerated, 'sourceProtected': block.isSourceProtected, + 'dirty': block.dirty, }; bool _boundedJsonDepth(String source) { @@ -389,12 +406,20 @@ bool _boundedJsonDepth(String source) { return true; } -BusyBlock _destinationSerializationBlock(BusyBlock block) => BusyBlock( +BusyBlock _destinationSerializationBlock( + BusyBlock block, { + bool authoredStructure = false, +}) => BusyBlock( id: block.id, kind: block.kind, inlines: block.inlines, children: [ - for (final child in block.children) _destinationSerializationBlock(child), + for (final child in block.children) + _destinationSerializationBlock( + child, + authoredStructure: + authoredStructure || busyMarkIsWritersideContainer(block), + ), ], attributes: block.attributes, rawSource: block.rawSource, @@ -402,7 +427,9 @@ BusyBlock _destinationSerializationBlock(BusyBlock block) => BusyBlock( isSourceOnly: block.isSourceOnly, isGenerated: block.isGenerated, isSourceProtected: false, - dirty: true, + dirty: authoredStructure || busyMarkIsWritersideContainer(block) + ? block.dirty + : true, ); class _FragmentReader { @@ -484,7 +511,7 @@ class _FragmentReader { isSourceOnly: boolean(data['sourceOnly']), isGenerated: boolean(data['generated']), isSourceProtected: boolean(data['sourceProtected']), - dirty: true, + dirty: data['dirty'] == null || boolean(data['dirty']), ); } } diff --git a/lib/src/editor/wysiwyg/wysiwyg_commands.dart b/lib/src/editor/wysiwyg/wysiwyg_commands.dart index 2edb3cf9..f3ae02cb 100644 --- a/lib/src/editor/wysiwyg/wysiwyg_commands.dart +++ b/lib/src/editor/wysiwyg/wysiwyg_commands.dart @@ -24,6 +24,21 @@ enum BusyWysiwygInlineCommand { strikethrough, code, link, + uiControl, + filePath, + uiPath, + shortcut, +} + +enum BusyWritersideInsertCommand { + procedure, + tabs, + definitionList, + include, + variable, + video, + tldr, + convertListToProcedure, } /// Whether [block] can safely be replaced by a generic block command. @@ -41,6 +56,7 @@ bool busyMarkWysiwygCanApplyBlockCommand( return true; } if (block.preserveRaw || + busyMarkIsWritersideContainer(block) || block.isSourceOnly || block.isGenerated || block.isSourceProtected) { @@ -144,5 +160,9 @@ BusyInlineKind inlineKindForCommand(BusyWysiwygInlineCommand command) { BusyWysiwygInlineCommand.strikethrough => BusyInlineKind.strikethrough, BusyWysiwygInlineCommand.code => BusyInlineKind.code, BusyWysiwygInlineCommand.link => BusyInlineKind.link, + BusyWysiwygInlineCommand.uiControl => BusyInlineKind.writersideControl, + BusyWysiwygInlineCommand.filePath => BusyInlineKind.writersidePath, + BusyWysiwygInlineCommand.uiPath => BusyInlineKind.writersideUiPath, + BusyWysiwygInlineCommand.shortcut => BusyInlineKind.writersideShortcut, }; } diff --git a/lib/src/editor/wysiwyg/wysiwyg_document_controller.dart b/lib/src/editor/wysiwyg/wysiwyg_document_controller.dart index 177ef9e0..fd36e17d 100644 --- a/lib/src/editor/wysiwyg/wysiwyg_document_controller.dart +++ b/lib/src/editor/wysiwyg/wysiwyg_document_controller.dart @@ -5,12 +5,16 @@ import '../../markdown/busymark_document.dart'; import '../../markdown/busymark_markdown_serializer.dart'; import '../../markdown/markdown_model.dart'; import '../../markdown/markdown_parser.dart'; +import '../../markdown/markdown_front_matter.dart'; import '../../markdown/math_syntax.dart'; import '../../markdown/raw_html_adapter.dart'; import '../../spellcheck/spelling_replacement.dart'; import '../inline_semantics.dart'; import 'wysiwyg_commands.dart'; import 'wysiwyg_inline_controller.dart'; +import 'writerside_editing_adapter.dart'; +import '../../writerside/writerside_schema.dart'; +import '../../writerside/writerside_project.dart'; BusyBlock busyMarkWysiwygImmutableBlockSnapshot(BusyBlock block) { BusyInline snapshotInline(BusyInline inline) { @@ -52,7 +56,9 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { BusyMarkWysiwygDocumentController({ required BusyDocument document, BusyMarkMarkdownSerializer serializer = const BusyMarkMarkdownSerializer(), - }) : _document = _ensureEditableDocument(document), + }) : _document = _ensureEditableDocument( + busyMarkWysiwygBindMarkdownReferences(document), + ), _serializer = serializer; BusyDocument _document; @@ -61,7 +67,15 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { BusyDocument get document => _document; - String get markdown => _serializer.serialize(_document); + String get markdown => _document.isXmlTopic + ? const WritersideEditingAdapter().serializeXml(_document) + : _serializer.serialize(_document); + + void rebaseCommittedSource(String source) { + if (_document.isXmlTopic) { + _document = const WritersideEditingAdapter().rebaseXml(_document, source); + } + } @override void notifyListeners() { @@ -70,7 +84,9 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { } void replaceDocument(BusyDocument document) { - _document = _ensureEditableDocument(document); + _document = _ensureEditableDocument( + busyMarkWysiwygBindMarkdownReferences(document), + ); notifyListeners(); } @@ -79,6 +95,730 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { return block == null ? '' : busyMarkWysiwygEditableText(block); } + List elementPath(String blockId) { + List? find(List blocks) { + for (final block in blocks) { + if (block.id == blockId) return [block]; + final path = find(block.children); + if (path != null) return [block, ...path]; + } + return null; + } + + final path = find(_document.blocks) ?? []; + if (!_document.isXmlTopic && path.isNotEmpty) { + final rootIndex = _document.blocks.indexWhere( + (b) => b.id == path.first.id, + ); + final headings = []; + for (final candidate in _document.blocks.take(rootIndex + 1)) { + if (candidate.kind != BusyBlockKind.heading) continue; + final level = int.tryParse(candidate.attributes['level'] ?? '') ?? 1; + headings.removeWhere( + (h) => (int.tryParse(h.attributes['level'] ?? '') ?? 1) >= level, + ); + if (candidate.id != blockId) headings.add(candidate); + } + return [...headings, ...path]; + } + return path; + } + + List availableVariableReferences( + WritersideProjectIndex index, + String moduleId, + String blockId, + ) { + final target = blockById(blockId); + if (target == null) { + return const []; + } + final names = { + for (final s in index.symbols) + if (s.moduleId == moduleId && + s.kind == WritersideSymbolKind.variable && + s.scopeSpan == null) + s.name, + }; + // Read local declarations from authored parents, avoiding stale index + // offsets after structural edits. Markdown root declarations share topic + // scope, while a declaration inside a tab/chapter remains in that parent. + final scopes = [ + if (!_document.isXmlTopic) _document.blocks, + for (final parent in elementPath(blockId)) parent.children, + ]; + for (final children in scopes) { + for (final child in children) { + final name = child.attributes['name']; + if (child.attributes['element'] == 'var' && name != null) { + names.add(name); + } + } + } + return names.toList()..sort(); + } + + bool canInsertWriterside( + BusyWritersideInsertCommand command, + String? blockId, + ) { + if (!_document.isWriterside || blockId == null) return false; + final target = blockById(blockId); + if (target == null || + target.preserveRaw || + target.isSourceProtected || + target.isGenerated) { + return false; + } + if (command == BusyWritersideInsertCommand.variable) { + return !busyMarkIsWritersideContainer(target) && + target.kind == BusyBlockKind.paragraph; + } + if (command == BusyWritersideInsertCommand.convertListToProcedure) { + return _listToProcedureSelection(blockId) != null; + } + final parent = _insertionParent(target); + final tag = switch (command) { + BusyWritersideInsertCommand.procedure => 'procedure', + BusyWritersideInsertCommand.tabs => 'tabs', + BusyWritersideInsertCommand.definitionList => 'deflist', + BusyWritersideInsertCommand.include => 'include', + BusyWritersideInsertCommand.video => 'video', + BusyWritersideInsertCommand.tldr => 'tldr', + _ => '', + }; + final parentTag = parent == null + ? 'topic' + : parent.attributes['element'] ?? 'chapter'; + if (!WritersideSchema.childElementNames(parentTag).contains(tag)) { + return false; + } + if (command == BusyWritersideInsertCommand.tldr) { + if (!{'topic', 'chapter'}.contains(parentTag)) return false; + final siblings = parent?.children ?? _document.blocks; + if (_document.isXmlTopic || + parent != null && busyMarkIsWritersideContainer(parent)) { + if (siblings.any((b) => b.attributes['element'] == 'tldr')) { + return false; + } + } else if (_document.blocks.any( + (b) => + b.attributes['element'] == 'tldr' && + elementPath(b.id) + .where((p) => p.kind == BusyBlockKind.heading) + .lastOrNull + ?.id == + parent?.id, + )) { + return false; + } + } + return true; + } + + BusyBlock? _insertionParent(BusyBlock target) { + if (!_document.isXmlTopic && target.kind == BusyBlockKind.heading) { + return target; + } + if (busyMarkIsWritersideContainer(target) && + !{ + 'tabs', + 'deflist', + 'list', + 'procedure', + }.contains(target.attributes['element'])) { + return target; + } + final path = elementPath(target.id); + for (final parent in path.reversed.skip(1)) { + if (busyMarkIsWritersideContainer(parent)) return parent; + if (!_document.isXmlTopic && parent.kind == BusyBlockKind.heading) { + return parent; + } + } + return null; + } + + BusyBlock _newWritersideElement( + String tag, { + String title = '', + Map attributes = const {}, + List children = const [], + }) { + final container = WritersideEditingAdapter.containers.contains(tag); + return BusyBlock( + id: _nextGeneratedBlockId(tag), + kind: switch (tag) { + 'procedure' => BusyBlockKind.writersideProcedure, + 'tabs' || 'tab' => BusyBlockKind.writersideTabs, + 'video' => BusyBlockKind.video, + 'p' => BusyBlockKind.paragraph, + _ => BusyBlockKind.writersideRawXml, + }, + inlines: WritersideEditingAdapter.titleElements.contains(tag) + ? [BusyInline(kind: BusyInlineKind.text, text: title)] + : const [], + children: children, + dirty: true, + attributes: { + 'element': tag, + ...attributes, + if (container) busyMarkWritersideContainerAttribute: 'true', + if (WritersideEditingAdapter.titleElements.contains(tag)) + 'title': title, + }, + ); + } + + String? insertWriterside( + BusyWritersideInsertCommand command, + String blockId, { + required String title, + Map attributes = const {}, + }) { + if (!canInsertWriterside(command, blockId)) return null; + if (command == BusyWritersideInsertCommand.convertListToProcedure) { + return convertListToProcedure(blockId, title: title); + } + final tag = switch (command) { + BusyWritersideInsertCommand.procedure => 'procedure', + BusyWritersideInsertCommand.tabs => 'tabs', + BusyWritersideInsertCommand.definitionList => 'deflist', + BusyWritersideInsertCommand.include => 'include', + BusyWritersideInsertCommand.video => 'video', + BusyWritersideInsertCommand.tldr => 'tldr', + _ => '', + }; + if (tag.isEmpty) return null; + for (final required in WritersideSchema.requiredAttributesFor(tag)) { + if ((attributes[required] ?? '').trim().isEmpty) return null; + } + final itemTag = switch (tag) { + 'procedure' => 'step', + 'tabs' => 'tab', + 'deflist' => 'def', + _ => null, + }; + final inserted = _newWritersideElement( + tag, + title: title, + attributes: attributes, + children: itemTag == null + ? tag == 'tldr' + ? [_newWritersideElement('p')] + : [] + : [ + _newWritersideElement( + itemTag, + title: title, + children: [_newWritersideElement('p')], + ), + ], + ); + final target = blockById(blockId)!; + final parent = _insertionParent(target); + if (parent != null && busyMarkIsWritersideContainer(parent)) { + _replaceBlock(parent.id, (block) { + final children = [...block.children]; + var index = children.indexWhere((b) => b.id == target.id); + if (tag == 'tldr') { + index = children.indexWhere( + (b) => !b.isSourceOnly && b.attributes['element'] != 'title', + ); + children.insert(index < 0 ? children.length : index, inserted); + } else { + children.insert(index < 0 ? children.length : index + 1, inserted); + } + return block.copyWith(children: children, dirty: true); + }); + } else { + final anchor = tag == 'tldr' && parent != null ? parent.id : blockId; + _document = _document.copyWith( + blocks: _insertBlocksAfter(_document.blocks, anchor, [inserted]), + ); + notifyListeners(); + } + return inserted.id; + } + + ({BusyBlock? list, List items, BusyBlockKind kind})? + _listToProcedureSelection(String blockId) { + final target = elementPath( + blockId, + ).where((b) => _isListItemKind(b.kind)).lastOrNull; + if (target == null || target.kind == BusyBlockKind.taskListItem) { + return null; + } + final path = elementPath(target.id); + final list = path + .where((b) => b.attributes['element'] == 'list') + .lastOrNull; + final siblings = + list?.children ?? + (path.length > 1 && busyMarkIsWritersideContainer(path[path.length - 2]) + ? path[path.length - 2].children + : _document.blocks); + final index = siblings.indexWhere((b) => b.id == target.id); + if (index < 0) return null; + var start = index, end = index + 1; + while (start > 0 && siblings[start - 1].kind == target.kind) { + start--; + } + while (end < siblings.length && siblings[end].kind == target.kind) { + end++; + } + final items = list == null + ? siblings.sublist(start, end) + : siblings.where((b) => !b.isSourceOnly).toList(); + if (items.any( + (b) => + !_isListItemKind(b.kind) || + b.preserveRaw || + b.isSourceProtected || + WritersideEditingAdapter.sourceAttributes(b).keys.any( + (a) => !WritersideSchema.attributesFor('step').contains(a), + ), + )) { + return null; + } + if (list != null && + WritersideEditingAdapter.sourceAttributes(list).keys.any( + (a) => + a != 'type' && + !WritersideSchema.attributesFor('procedure').contains(a), + )) { + return null; + } + final parentPath = elementPath(list?.id ?? target.id); + final parent = parentPath.length > 1 + ? parentPath[parentPath.length - 2] + : null; + final parentTag = parent == null + ? 'topic' + : parent.kind == BusyBlockKind.heading && !_document.isXmlTopic + ? 'chapter' + : WritersideEditingAdapter.tagForBlock(parent); + if (!WritersideSchema.childElementNames(parentTag).contains('procedure')) { + return null; + } + return (list: list, items: items, kind: target.kind); + } + + String? convertListToProcedure(String blockId, {required String title}) { + final selection = _listToProcedureSelection(blockId); + if (selection == null) return null; + final list = selection.list; + final items = selection.items; + final steps = [ + for (final item in items) + _newWritersideElement( + 'step', + attributes: WritersideEditingAdapter.sourceAttributes(item), + children: [ + if (item.inlines.isNotEmpty) + BusyBlock( + id: _nextGeneratedBlockId('p'), + kind: BusyBlockKind.paragraph, + inlines: item.inlines, + dirty: true, + ), + ...item.children, + ], + ), + ]; + final procedure = _newWritersideElement( + 'procedure', + title: title, + attributes: { + if (list != null) ...{ + for (final e in WritersideEditingAdapter.sourceAttributes( + list, + ).entries) + if (WritersideSchema.attributesFor('procedure').contains(e.key) && + e.key != 'type') + e.key: e.value, + }, + 'type': selection.kind == BusyBlockKind.orderedListItem + ? 'steps' + : 'choices', + }, + children: list == null + ? steps + : [ + for (final child in list.children) + if (child.isSourceOnly) child else steps[items.indexOf(child)], + ], + ); + if (list != null) { + _document = _document.copyWith( + blocks: _replaceBlockWithMany(_document.blocks, list.id, [procedure]), + ); + } else { + var blocks = _document.blocks; + for (final item in items) { + blocks = _replaceBlockWithMany( + blocks, + item.id, + item == items.first ? [procedure] : [], + ); + } + _document = _document.copyWith(blocks: blocks); + } + notifyListeners(); + return procedure.id; + } + + bool updateWritersideProperty( + String blockId, + String attribute, + String? value, + ) { + final block = blockById(blockId); + if (block == null || block.isSourceProtected || block.preserveRaw) { + return false; + } + final tag = block.kind == BusyBlockKind.heading && !_document.isXmlTopic + ? 'chapter' + : WritersideEditingAdapter.tagForBlock(block); + final name = attribute == 'language' ? 'lang' : attribute; + if (name == 'id' && + value != null && + value.isNotEmpty && + _flatten(_document.blocks).any( + (b) => + b.id != blockId && + b.attributes['id'] == value && + b.attributes['generatedId'] != 'true', + )) { + return false; + } + if (block.kind == BusyBlockKind.heading && + !_document.isXmlTopic && + block.attributes['level'] == '1' && + {'collapsible', 'default-state'}.contains(name)) { + return false; + } + if (!WritersideSchema.attributesFor(tag).contains(name)) return false; + if (value != null && + WritersideSchema.invalidAttributeValue(tag, name, value) != null) { + return false; + } + if ((value == null || value.isEmpty) && + WritersideSchema.requiredAttributesFor(tag).contains(name)) { + return false; + } + if (block.attributes[attribute] == value) return false; + _replaceBlock(blockId, (block) { + final attrs = {...block.attributes}; + if (value == null || value.isEmpty) { + attrs.remove(attribute); + } else { + attrs[attribute] = value; + } + if (attribute == 'language' && attrs['element'] == 'code-block') { + attrs['lang'] = value ?? ''; + } + if (attribute == 'id' && block.kind == BusyBlockKind.heading) { + attrs['generatedId'] = 'false'; + } + return block.copyWith( + attributes: attrs, + dirty: true, + inlines: attribute == 'title' && busyMarkIsWritersideContainer(block) + ? [BusyInline(kind: BusyInlineKind.text, text: value ?? '')] + : block.inlines, + ); + }); + if (tag == 'def' && attribute != 'title' && !_document.isXmlTopic) { + final list = elementPath( + blockId, + ).where((b) => b.attributes['element'] == 'deflist').lastOrNull; + if (list?.attributes['busymark-definition-form'] == 'markdown') { + _replaceBlock( + list!.id, + (b) => b.copyWith( + attributes: {...b.attributes}..remove('busymark-definition-form'), + dirty: true, + ), + ); + } + } + return true; + } + + bool manageWritersideItem( + String containerId, { + String? itemId, + int? direction, + bool remove = false, + required String title, + }) { + final group = blockById(containerId); + final tag = switch (group?.attributes['element']) { + 'tabs' => 'tab', + 'procedure' => 'step', + 'deflist' => 'def', + _ => null, + }; + if (group == null || + tag == null || + group.preserveRaw || + group.isSourceProtected) { + return false; + } + final children = [...group.children]; + if (itemId == null) { + children.add( + _newWritersideElement( + tag, + title: title, + children: [_newWritersideElement('p')], + ), + ); + } else { + final index = children.indexWhere( + (b) => b.id == itemId && b.attributes['element'] == tag, + ); + if (index < 0) return false; + if (remove) { + if (children.where((b) => b.attributes['element'] == tag).length < 2) { + return false; + } + children.removeAt(index); + } else { + final items = children + .where((b) => b.attributes['element'] == tag) + .toList(); + final ordinal = items.indexWhere((b) => b.id == itemId); + final next = ordinal + (direction ?? 0); + if (next < 0 || next >= items.length || next == ordinal) return false; + final other = children.indexOf(items[next]); + children[index] = items[next]; + children[other] = items[ordinal]; + } + } + _replaceBlock( + containerId, + (b) => b.copyWith(children: children, dirty: true), + ); + return true; + } + + void insertVariableReference( + String blockId, + String name, + int start, + int end, + ) { + final block = blockById(blockId); + if (block == null || + !canInsertWriterside(BusyWritersideInsertCommand.variable, blockId)) { + return; + } + final value = BusyInline( + kind: BusyInlineKind.writersideVariable, + text: name, + attributes: {'reference': name}, + ); + final before = busyMarkWysiwygClipboardInlineSlice(block.inlines, 0, start); + final after = busyMarkWysiwygClipboardInlineSlice( + block.inlines, + end, + block.plainText.length, + ); + _replaceBlock( + blockId, + (b) => b.copyWith(inlines: [...before, value, ...after], dirty: true), + ); + } + + bool updateTopicSwitcherLabel(String value) { + if (_document.isXmlTopic) { + final root = _document.blocks + .where((b) => b.attributes['element'] == 'topic') + .firstOrNull; + return root != null && + updateWritersideProperty(root.id, 'switcher-label', value); + } + if (!_document.isWriterside || + _document.frontMatter['switcher-label'] == value || + value.isEmpty && !_document.frontMatter.containsKey('switcher-label')) { + return false; + } + final raw = _document.rawFrontMatter; + final line = 'switcher-label: ${encodeWritersideSwitcherLabel(value)}'; + final nextRaw = value.isEmpty + ? (raw ?? '').replaceFirst( + RegExp(r'^switcher-label:[^\r\n]*(?:\r?\n|$)', multiLine: true), + '', + ) + : raw == null + ? '---\n$line\n---\n' + : RegExp(r'^switcher-label:.*$', multiLine: true).hasMatch(raw) + ? raw.replaceFirst( + RegExp(r'^switcher-label:.*$', multiLine: true), + line, + ) + : raw.replaceFirst(RegExp(r'^---\s*\r?\n'), '---\n$line\n'); + final source = markdown; + final nextSource = raw == null + ? '$nextRaw\n$source' + : source.replaceFirst(raw, nextRaw); + _document = _document.copyWith( + source: nextSource, + rawFrontMatter: nextRaw, + frontMatter: { + for (final entry in _document.frontMatter.entries) + if (entry.key != 'switcher-label') entry.key: entry.value, + if (value.isNotEmpty) 'switcher-label': value, + }, + ); + notifyListeners(); + return true; + } + + bool updateTopicNavigation(String attribute, String value) { + if (!_document.isWriterside || + !{'for', 'depth'}.contains(attribute) || + WritersideSchema.invalidAttributeValue( + 'show-structure', + attribute, + value, + ) != + null) { + return false; + } + final root = _document.blocks + .where((b) => b.attributes['element'] == 'topic') + .firstOrNull; + final siblings = root?.children ?? _document.blocks; + final existing = siblings + .where((b) => b.attributes['element'] == 'show-structure') + .firstOrNull; + if (existing != null) { + return updateWritersideProperty(existing.id, attribute, value); + } + final node = _newWritersideElement( + 'show-structure', + attributes: {attribute: value}, + ).copyWith(isSourceOnly: _document.isXmlTopic); + if (root != null) { + _replaceBlock( + root.id, + (b) => b.copyWith(children: [node, ...b.children], dirty: true), + ); + } else { + final heading = siblings + .where( + (b) => + b.kind == BusyBlockKind.heading && b.attributes['level'] == '1', + ) + .firstOrNull; + _document = _document.copyWith( + blocks: heading == null + ? [node, ...siblings] + : _insertBlocksAfter(siblings, heading.id, [node]), + ); + notifyListeners(); + } + return true; + } + + bool updateInlineReference( + String blockId, + BusyInlineStyleRange target, + String attribute, + String value, + ) { + final block = blockById(blockId); + if (block == null || block.preserveRaw) { + return false; + } + final ranges = busyInlineReferenceRanges(block.inlines); + final index = ranges.indexWhere( + (r) => + r.start == target.start && + r.end == target.end && + r.kind == target.kind && + mapEquals(r.attributes, target.attributes), + ); + if (index < 0) return false; + final range = ranges[index]; + if (range.kind == BusyInlineKind.writersideVariable) { + if (value.trim().isEmpty) { + return false; + } + final before = busyMarkWysiwygClipboardInlineSlice( + block.inlines, + 0, + range.start, + ); + final after = busyMarkWysiwygClipboardInlineSlice( + block.inlines, + range.end, + block.plainText.length, + ); + _replaceBlock( + blockId, + (b) => b.copyWith( + inlines: [ + ...before, + BusyInline( + kind: range.kind, + text: value, + attributes: {...range.attributes, 'reference': value}, + ), + ...after, + ], + dirty: true, + ), + ); + return true; + } + if (range.kind != BusyInlineKind.writersideShortcut || + !{'key', 'from-keymap-of', 'force-layout'}.contains(attribute) || + value.isNotEmpty && + WritersideSchema.invalidAttributeValue( + 'shortcut', + attribute, + value, + ) != + null || + attribute == 'key' && value.isEmpty && range.start == range.end) { + return false; + } + var offset = 0; + var replaced = false; + BusyInline replace(BusyInline inline) { + final start = offset; + if (!replaced && + inline.kind == range.kind && + start == range.start && + start + inline.plainText.length == range.end && + mapEquals(inline.attributes, range.attributes)) { + offset += inline.plainText.length; + replaced = true; + final attributes = {...inline.attributes}; + if (value.isEmpty) { + attributes.remove(attribute); + } else { + attributes[attribute] = value; + } + return inline.copyWith(attributes: attributes); + } + if (inline.children.isNotEmpty) { + return inline.copyWith(children: inline.children.map(replace).toList()); + } + offset += inline.plainText.length; + return inline; + } + + final inlines = block.inlines.map(replace).toList(); + if (!replaced) return false; + _replaceBlock(blockId, (b) => b.copyWith(inlines: inlines, dirty: true)); + return true; + } + String _blockStructurePrefix(BusyBlock block) { return switch (block.kind) { BusyBlockKind.heading => @@ -439,6 +1179,7 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { } String? insertDisplayMathAfter(String blockId, {String expression = 'x'}) { + if (_document.isXmlTopic) return null; if (blockById(blockId) == null) { return null; } @@ -951,7 +1692,11 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { void applyBlockCommand(String blockId, BusyWysiwygBlockCommand command) { final block = blockById(blockId); - if (block == null || !busyMarkWysiwygCanApplyBlockCommand(block, command)) { + if (block == null || !canApplyBlockCommand(block, command)) { + return; + } + if (_document.isXmlTopic) { + _replaceBlock(blockId, (b) => _xmlBlockWithCommand(b, command)); return; } if (command == BusyWysiwygBlockCommand.thematicBreak) { @@ -985,8 +1730,7 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { } final eligibleIds = ids.where((id) { final block = blockById(id); - return block != null && - busyMarkWysiwygCanApplyBlockCommand(block, command); + return block != null && canApplyBlockCommand(block, command); }).toList(); if (eligibleIds.isEmpty) { return; @@ -997,6 +1741,7 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { } final idSet = eligibleIds.toSet(); final replaced = _replaceBlocksByIds(_document.blocks, idSet, (block) { + if (_document.isXmlTopic) return _xmlBlockWithCommand(block, command); return _blockWithCommand( block, command, @@ -1011,6 +1756,86 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { notifyListeners(); } + bool canApplyBlockCommand(BusyBlock block, BusyWysiwygBlockCommand command) { + if (!busyMarkWysiwygCanApplyBlockCommand(block, command)) return false; + if (!_document.isXmlTopic) return true; + final tag = switch (blockKindForCommand(command)) { + BusyBlockKind.heading => 'chapter', + BusyBlockKind.codeBlock => 'code-block', + BusyBlockKind.unorderedListItem || + BusyBlockKind.orderedListItem || + BusyBlockKind.taskListItem => 'list', + BusyBlockKind.blockquote => 'quote', + BusyBlockKind.paragraph => 'p', + BusyBlockKind.image => 'img', + _ => '', + }; + final parent = elementPath(block.id).reversed.skip(1).firstOrNull; + if (parent == null || + !WritersideSchema.childElementNames( + parent.attributes['element'] ?? '', + ).contains(tag)) { + return false; + } + if (tag == 'chapter' && + block.inlines.any( + (i) => + i.kind != BusyInlineKind.text && + i.kind != BusyInlineKind.softBreak, + )) { + return false; + } + return true; + } + + BusyBlock _xmlBlockWithCommand( + BusyBlock block, + BusyWysiwygBlockCommand command, + ) { + final kind = blockKindForCommand(command); + if (_isListItemKind(kind)) { + return _newWritersideElement( + 'list', + attributes: { + 'type': kind == BusyBlockKind.orderedListItem + ? 'decimal' + : kind == BusyBlockKind.taskListItem + ? 'checkbox' + : 'bullet', + }, + children: [ + _newWritersideElement('li', children: [block]).copyWith( + kind: kind, + attributes: { + 'element': 'li', + busyMarkWritersideContainerAttribute: 'true', + 'marker': kind == BusyBlockKind.orderedListItem ? '1.' : '-', + }, + ), + ], + ); + } + if (kind == BusyBlockKind.blockquote) { + return _xmlAdmonitionStyle(block, BusyAdmonitionStyle.quote); + } + final attrs = { + ...block.attributes, + 'element': kind == BusyBlockKind.heading + ? 'chapter' + : kind == BusyBlockKind.codeBlock + ? 'code-block' + : 'p', + }..remove(busyMarkWritersideContainerAttribute); + if (kind == BusyBlockKind.heading) { + attrs.addAll({ + busyMarkWritersideContainerAttribute: 'true', + 'title': block.plainText, + 'level': '2', + }); + } + return block.copyWith(kind: kind, attributes: attrs, dirty: true); + } + void applyAdmonitionStyle(String blockId, BusyAdmonitionStyle style) { final block = blockById(blockId); if (block == null || !busyMarkWysiwygCanApplyAdmonitionStyle(block)) { @@ -1020,16 +1845,46 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { blocks: _replaceInBlocks( _document.blocks, blockId, - (block) => _blockWithAdmonitionStyle( - block, - style, - nextId: () => _nextGeneratedBlockId('paragraph'), - ), + (block) => _document.isXmlTopic + ? _xmlAdmonitionStyle(block, style) + : _blockWithAdmonitionStyle( + block, + style, + nextId: () => _nextGeneratedBlockId('paragraph'), + ), ), ); notifyListeners(); } + BusyBlock _xmlAdmonitionStyle(BusyBlock block, BusyAdmonitionStyle style) => + block.copyWith( + kind: BusyBlockKind.writersideAdmonition, + attributes: { + ...block.attributes, + 'element': style.name, + 'style': style.name, + busyMarkWritersideContainerAttribute: 'true', + busyMarkWritersideAdmonitionAttribute: 'true', + busyMarkWritersideAdmonitionSourceFormAttribute: 'element', + }, + inlines: const [], + children: busyMarkIsWritersideContainer(block) + ? block.children + : [ + if (block.inlines.isNotEmpty) + BusyBlock( + id: _nextGeneratedBlockId('p'), + kind: BusyBlockKind.paragraph, + inlines: block.inlines, + dirty: true, + ), + ...block.children, + ], + dirty: true, + preserveRaw: false, + ); + void applyAdmonitionStyleToBlocks( Iterable blockIds, BusyAdmonitionStyle style, @@ -1047,11 +1902,13 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { blocks: _replaceBlocksByIds( _document.blocks, ids, - (block) => _blockWithAdmonitionStyle( - block, - style, - nextId: () => _nextGeneratedBlockId('paragraph'), - ), + (block) => _document.isXmlTopic + ? _xmlAdmonitionStyle(block, style) + : _blockWithAdmonitionStyle( + block, + style, + nextId: () => _nextGeneratedBlockId('paragraph'), + ), ), ); notifyListeners(); @@ -1087,7 +1944,11 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { attributes: {'src': trimmedSource, 'alt': trimmedAlt}, ), ], - attributes: {...block.attributes, 'src': trimmedSource}, + attributes: { + ...block.attributes, + 'src': trimmedSource, + if (_document.isXmlTopic) ...{'element': 'img', 'alt': trimmedAlt}, + }, dirty: true, ), ); @@ -1101,6 +1962,10 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { required String alt, required String fallbackAltText, }) { + if (_document.isXmlTopic && + blockById(blockId)?.kind != BusyBlockKind.paragraph) { + return; + } final trimmedSource = source.trim(); if (trimmedSource.isEmpty) { return; @@ -1178,6 +2043,7 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { required String Function(int columnNumber) headerTextForColumn, required String cellText, }) { + if (_document.isXmlTopic) return null; if (blockById(blockId) == null) { return null; } @@ -1205,6 +2071,7 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { } String? insertRawHtmlBlockAfter(String blockId, String rawSource) { + if (_document.isXmlTopic) return null; if (blockById(blockId) == null) { return null; } @@ -1833,6 +2700,18 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { if (block == null) { return null; } + if (_document.isXmlTopic && + busyMarkIsWritersideContainer(block) && + WritersideEditingAdapter.titleElements.contains( + block.attributes['element'], + )) { + final paragraph = _newWritersideElement('p'); + _replaceBlock( + blockId, + (b) => b.copyWith(children: [...b.children, paragraph], dirty: true), + ); + return BusyWysiwygTextSplitResult(blockId: paragraph.id, offset: 0); + } if (block.kind == BusyBlockKind.codeBlock) { final text = block.plainText; final safeOffset = offset.clamp(0, text.length).toInt(); @@ -2118,6 +2997,57 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { if (block == null || block.preserveRaw) { return null; } + if (_document.isXmlTopic) { + if (block.kind != BusyBlockKind.paragraph) return null; + bool supportedInline(BusyInline inline) => + inline.kind != BusyInlineKind.math && + (inline.kind != BusyInlineKind.html || + inline.attributes.containsKey('xml-raw')) && + inline.children.every(supportedInline); + bool supportedBlock(BusyBlock block) { + if (block.preserveRaw || block.isSourceProtected) { + final raw = block.rawSource?.trimLeft(); + return raw != null && + (raw.startsWith('<') || block.isSourceOnly && raw.isEmpty); + } + if ({ + BusyBlockKind.table, + BusyBlockKind.math, + BusyBlockKind.htmlBlock, + }.contains(block.kind) || + !busyMarkIsWritersideContainer(block) && + { + BusyBlockKind.heading, + BusyBlockKind.orderedListItem, + BusyBlockKind.unorderedListItem, + BusyBlockKind.taskListItem, + }.contains(block.kind)) { + return false; + } + return block.inlines.every(supportedInline) && + block.children.every(supportedBlock); + } + + if (blocks.any( + (b) => !supportedBlock(busyMarkWysiwygClipboardBlock(b)), + )) { + return null; + } + for (final styled in blocks) { + final command = switch (styled.completeBlock?.attributes['element']) { + 'procedure' => BusyWritersideInsertCommand.procedure, + 'tabs' => BusyWritersideInsertCommand.tabs, + 'deflist' => BusyWritersideInsertCommand.definitionList, + 'tldr' => BusyWritersideInsertCommand.tldr, + 'include' => BusyWritersideInsertCommand.include, + 'video' => BusyWritersideInsertCommand.video, + _ => null, + }; + if (command != null && !canInsertWriterside(command, blockId)) { + return null; + } + } + } final text = block.plainText; final start = selectionStart.clamp(0, text.length).toInt(); final end = selectionEnd.clamp(start, text.length).toInt(); @@ -2641,7 +3571,12 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { int selectionStart, int selectionEnd, { String? destination, + Map linkAttributes = const {}, }) { + if (_document.isXmlTopic && + blockById(blockId)?.kind != BusyBlockKind.paragraph) { + return; + } if (selectionStart == selectionEnd) { return; } @@ -2655,6 +3590,7 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { start, end, destination: destination, + linkAttributes: linkAttributes, ), ); } @@ -2670,6 +3606,9 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { } _document = _document.copyWith( blocks: _replaceBlocksByIds(_document.blocks, idSet, (block) { + if (_document.isXmlTopic && block.kind != BusyBlockKind.paragraph) { + return block; + } final length = block.plainText.length; if (length == 0) { return block; @@ -2901,7 +3840,9 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { children: List.unmodifiable([ for (final child in block.children) _cloneClipboardBlock(child), ]), - attributes: Map.unmodifiable(block.attributes), + attributes: Map.unmodifiable( + {...block.attributes}..remove(busyMarkXmlBindingAttribute), + ), rawSource: block.rawSource, preserveRaw: block.preserveRaw, isSourceOnly: block.isSourceOnly, @@ -4298,6 +5239,7 @@ BusyBlock _blockWithAdmonitionStyle( ..['style'] = style.name; if (semanticElement) { attributes['element'] = style.name; + if (block.attributes['id'] case final id?) attributes['id'] = id; } else { attributes.remove('element'); } @@ -4452,6 +5394,7 @@ BusyBlock _blockWithInlineCommand( int selectionStart, int selectionEnd, { String? destination, + Map linkAttributes = const {}, }) { final text = block.plainText; final safeStart = selectionStart.clamp(0, text.length).toInt(); @@ -4477,6 +5420,9 @@ BusyBlock _blockWithInlineCommand( start: safeStart, end: safeEnd, kind: inlineKind, + attributes: command == BusyWysiwygInlineCommand.link + ? linkAttributes + : const {}, destination: command == BusyWysiwygInlineCommand.link ? destination : null, @@ -4583,7 +5529,11 @@ bool _isTypingInlineKind(BusyInlineKind kind) { BusyInlineKind.emphasis || BusyInlineKind.underline || BusyInlineKind.strikethrough || - BusyInlineKind.code => true, + BusyInlineKind.code || + BusyInlineKind.writersideControl || + BusyInlineKind.writersidePath || + BusyInlineKind.writersideUiPath || + BusyInlineKind.writersideShortcut => true, _ => false, }; } @@ -4620,7 +5570,98 @@ String _incrementOrderedMarker(String? marker, int offset) { return '${value + offset}$suffix'; } +// The authored Markdown parser retains interpolation tokens for the preview +// resolver. Bind those tokens to the editor's reference representation on +// loading, without changing source, dirty flags, or the preview projection. +BusyDocument busyMarkWysiwygBindMarkdownReferences(BusyDocument document) { + if (document.mode != MarkdownMode.writersideMarkdown || document.isXmlTopic) { + return document; + } + bool ignores(Map attributes, bool inherited) => + switch (attributes['ignore-vars']) { + 'true' => true, + 'false' => false, + _ => inherited, + }; + List bind(List inlines, bool inherited) { + final result = []; + for (final inline in inlines) { + final ignore = ignores(inline.attributes, inherited); + if (ignore || + { + BusyInlineKind.code, + BusyInlineKind.math, + BusyInlineKind.html, + BusyInlineKind.writersideVariable, + }.contains(inline.kind)) { + result.add(inline); + } else if (inline.kind == BusyInlineKind.text) { + final parts = const WritersideEditingAdapter().textInlines(inline.text); + if (!parts.any((p) => p.kind == BusyInlineKind.writersideVariable)) { + result.add(inline); + } else { + result.addAll( + parts.map( + (part) => part.kind == BusyInlineKind.writersideVariable + ? part.copyWith( + attributes: {...inline.attributes, ...part.attributes}, + ) + : inline.copyWith(text: part.text), + ), + ); + } + } else if (inline.children.isNotEmpty) { + final children = bind(inline.children, ignore); + result.add( + listEquals(children, inline.children) + ? inline + : inline.copyWith(children: children), + ); + } else { + result.add(inline); + } + } + return result; + } + + BusyBlock visit(BusyBlock block, bool inherited) { + if (block.isSourceProtected || block.preserveRaw || block.isSourceOnly) { + return block; + } + final ignore = ignores(block.attributes, inherited); + final inlines = + { + BusyBlockKind.paragraph, + BusyBlockKind.heading, + BusyBlockKind.unorderedListItem, + BusyBlockKind.orderedListItem, + BusyBlockKind.taskListItem, + }.contains(block.kind) && + !WritersideEditingAdapter.titleElements.contains( + block.attributes['element'], + ) + ? bind(block.inlines, ignore) + : block.inlines; + final children = block.children.map((b) => visit(b, ignore)).toList(); + if (listEquals(inlines, block.inlines) && + listEquals(children, block.children)) { + return block; + } + return block.copyWith( + inlines: inlines, + children: children, + dirty: block.dirty, + ); + } + + final blocks = document.blocks.map((b) => visit(b, false)).toList(); + return listEquals(blocks, document.blocks) + ? document + : document.copyWith(blocks: blocks); +} + BusyDocument _ensureEditableDocument(BusyDocument document) { + if (document.isXmlTopic) return document; // Commit source-form changes before notifying listeners, within the edit's // existing transaction. Include dirty imported fragments as well as newly // reconstructed prose; untouched source-backed blocks retain their source. @@ -4894,7 +5935,11 @@ bool _canMergeInlineStyles(BusyInline left, BusyInline right) { BusyInlineKind.emphasis || BusyInlineKind.underline || BusyInlineKind.strikethrough || - BusyInlineKind.link => true, + BusyInlineKind.link || + BusyInlineKind.writersideControl || + BusyInlineKind.writersidePath || + BusyInlineKind.writersideUiPath || + BusyInlineKind.writersideShortcut => true, _ => false, }; } diff --git a/lib/src/editor/wysiwyg/wysiwyg_editor.dart b/lib/src/editor/wysiwyg/wysiwyg_editor.dart index 5b81f6d4..0a4ab163 100644 --- a/lib/src/editor/wysiwyg/wysiwyg_editor.dart +++ b/lib/src/editor/wysiwyg/wysiwyg_editor.dart @@ -1,3 +1,4 @@ +import '../link_destination.dart'; import '../../assets/document_media_context.dart'; import 'dart:async'; import 'dart:io'; @@ -57,6 +58,9 @@ import 'wysiwyg_document_controller.dart'; import 'wysiwyg_inline_controller.dart'; import 'wysiwyg_session_state.dart'; import 'wysiwyg_toolbar.dart'; +import 'writerside_editing_adapter.dart'; +import 'writerside_properties.dart'; +import '../../writerside/writerside_project.dart'; typedef BusyMarkWysiwygSourceChanged = void Function(String filePath, String source); @@ -93,6 +97,8 @@ class BusyMarkWysiwygEditor extends StatefulWidget { required this.document, required this.onSourceChanged, this.documentId, + this.writersideProjectIndex, + this.writersideModuleId, this.initialSessionState = const WysiwygEditorSessionState(), this.onSessionChanged, this.onTransactionalSourceChanged, @@ -113,6 +119,7 @@ class BusyMarkWysiwygEditor extends StatefulWidget { this.clipboardInsertionRegistry, this.onClipboardCaptured, this.onAssetSaveRequired, + this.onChooseLink, this.allowRemoteImages = false, this.onRemoteImageBlocked, this.toolbarPlacement = EditorToolbarPlacement.topLeft, @@ -139,6 +146,8 @@ class BusyMarkWysiwygEditor extends StatefulWidget { final BusyDocument document; final BusyMarkWysiwygSourceChanged onSourceChanged; final String? documentId; + final WritersideProjectIndex? writersideProjectIndex; + final String? writersideModuleId; final WysiwygEditorSessionState initialSessionState; final BusyMarkWysiwygSessionChanged? onSessionChanged; final BusyMarkWysiwygTransactionalSourceChanged? onTransactionalSourceChanged; @@ -159,6 +168,7 @@ class BusyMarkWysiwygEditor extends StatefulWidget { final BusyMarkClipboardInsertionRegistry? clipboardInsertionRegistry; final ValueChanged? onClipboardCaptured; final VoidCallback? onAssetSaveRequired; + final Future Function(String initial)? onChooseLink; final bool allowRemoteImages; final VoidCallback? onRemoteImageBlocked; final EditorToolbarPlacement toolbarPlacement; @@ -210,6 +220,11 @@ class BusyMarkWysiwygEditorState extends State { bool _hasReportedVisibleHeading = false; late final FocusNode _selectionFocusNode; String? _activeBlockId; + String? _propertiesTargetId; + int _propertiesDocumentRevision = 0; + String? _videoDraftTarget; + bool _propertiesVisible = true; + bool _topicPropertiesSelected = false; String? _activeCellId; final _collapsibleExpansion = {}; int _undoGroupSequence = 0; @@ -537,9 +552,19 @@ class BusyMarkWysiwygEditorState extends State { _listenForDroppedAssets(); } final oldDocumentId = oldWidget.documentId ?? oldWidget.document.filePath; - final fileChanged = oldDocumentId != _documentId; + final fileChanged = + oldDocumentId != _documentId || + oldWidget.document.isXmlTopic != widget.document.isXmlTopic || + oldWidget.document.mode != widget.document.mode; final sourceChanged = oldWidget.document.source != widget.document.source; if (fileChanged || (sourceChanged && !_internalChange)) { + // Pending property text belongs to the current authored element. An + // external replacement can reuse a projection ID for a different node. + // Recreate the panel entries rather than retargeting their drafts. + _propertiesDocumentRevision++; + _propertiesTargetId = null; + _videoDraftTarget = null; + _topicPropertiesSelected = false; _releaseSelectionDrag(rebuild: false); _pendingTextFocus = null; _hasReportedVisibleHeading = false; @@ -600,6 +625,9 @@ class BusyMarkWysiwygEditorState extends State { } void _resetPerDocumentState() { + _propertiesTargetId = null; + _videoDraftTarget = null; + _topicPropertiesSelected = false; for (final controller in _textControllers.values) { controller.dispose(); } @@ -691,6 +719,8 @@ class BusyMarkWysiwygEditorState extends State { @override Widget build(BuildContext context) { final colors = BusyMarkSurfaceColors.of(context); + final authoringGuard = _captureDialogTarget(); + final authoringBlockId = _activeBlockId; final commandRegistry = BusyMarkCommandRegistryScope.maybeOf(context) ?? BusyMarkCommandCatalog.metadata; @@ -842,7 +872,16 @@ class BusyMarkWysiwygEditorState extends State { ? constraints.maxHeight : null; final documentLayout = _documentLayout; - return Stack( + final showProperties = + _documentController.document.isWriterside && + _propertiesVisible; + final paneWidth = showProperties && constraints.maxWidth >= 900 + ? 300.0 + : 0.0; + final paneHeight = showProperties && paneWidth == 0 + ? math.min(230.0, constraints.maxHeight * .4) + : 0.0; + final editor = Stack( children: [ Positioned.fill( child: Listener( @@ -909,50 +948,92 @@ class BusyMarkWysiwygEditorState extends State { visible: _toolbarVisible, maxWidth: math.max( 0, - constraints.maxWidth - BusyMarkSpacing.lg, + constraints.maxWidth - paneWidth - BusyMarkSpacing.lg, ), maxHeight: math.max( 0, - constraints.maxHeight - BusyMarkSpacing.lg, + constraints.maxHeight - paneHeight - BusyMarkSpacing.lg, ), onToggle: () => setState(() => _toolbarVisible = !_toolbarVisible), onPlacementChanged: widget.onToolbarPlacementChanged, onDirectionChanged: widget.onToolbarDirectionChanged, child: BusyMarkWysiwygToolbar( + onMenuCanceled: () { + if (_isDialogTargetCurrent(authoringGuard)) { + _restoreEditingFocusAfterFrame(); + } + }, axis: widget.toolbarDirection._axis, alignEnd: _toolbarAlignedEnd( widget.toolbarPlacement, widget.toolbarDirection, ), onBlockCommand: _applyToolbarBlockCommand, - isBlockCommandEnabled: _canApplyBlockCommand, - onAdmonitionCommand: (style) => _applyToolbarCommand( - () => _applyAdmonitionCommand(style), + onWritersideInsert: (command) => unawaited( + _applyWritersideInsert( + command, + authoringGuard, + authoringBlockId, + ), + ), + isWritersideInsertEnabled: (command) => + _documentController.canInsertWriterside( + command, + authoringBlockId, + ) && + (!{ + BusyWritersideInsertCommand.include, + BusyWritersideInsertCommand.variable, + }.contains(command) || + widget.writersideProjectIndex != null), + onProperties: () => setState( + () => _propertiesVisible = !_propertiesVisible, ), + isBlockCommandEnabled: _canApplyBlockCommand, + onAdmonitionCommand: (style) { + if (!_isDialogTargetCurrent(authoringGuard)) return; + _applyToolbarCommand( + () => _applyAdmonitionCommand(style), + ); + }, admonitionCommandsEnabled: _canApplyAdmonitionCommand(), inlineCommandsEnabled: _hasInlineCommandTarget, lineBreakCommandsEnabled: _activeCellId == null, admonitionsEnabled: - _documentController.document.mode == - MarkdownMode.writersideMarkdown, - onInlineCommand: (command) => _applyToolbarCommand( - () => _applyInlineCommand(command), - ), + _documentController.document.isWriterside, + onInlineCommand: (command) { + if (busyMarkIsSemanticInline( + inlineKindForCommand(command), + ) && + !_isDialogTargetCurrent(authoringGuard)) { + return; + } + _applyToolbarCommand( + () => _applyInlineCommand(command), + ); + }, onLinkCommand: () => _applyAsyncToolbarCommand(_applyLinkCommand), - onInlineMathCommand: () => - _applyToolbarCommand(_applyInlineMathCommand), - onDisplayMathCommand: () => - _applyToolbarCommand(_applyDisplayMathCommand), + onInlineMathCommand: + _documentController.document.isXmlTopic + ? null + : () => _applyToolbarCommand(_applyInlineMathCommand), + onDisplayMathCommand: + _documentController.document.isXmlTopic + ? null + : () => + _applyToolbarCommand(_applyDisplayMathCommand), onImageCommand: () => _applyAsyncToolbarCommand(_applyImageCommand), onInlineImageCommand: () => _applyAsyncToolbarCommand(_applyInlineImageCommand), - onTableCommand: () => - _applyAsyncToolbarCommand(_applyTableCommand), - onHtmlCommand: () => - _applyAsyncToolbarCommand(_applyHtmlCommand), + onTableCommand: _documentController.document.isXmlTopic + ? null + : () => _applyAsyncToolbarCommand(_applyTableCommand), + onHtmlCommand: _documentController.document.isXmlTopic + ? null + : () => _applyAsyncToolbarCommand(_applyHtmlCommand), onIndentCommand: () => _applyToolbarCommand(_applyIndentCommand), onOutdentCommand: () => @@ -967,6 +1048,21 @@ class BusyMarkWysiwygEditorState extends State { ), ], ); + if (!showProperties) return editor; + final panel = _writersidePropertiesPanel(); + return paneWidth > 0 + ? Row( + children: [ + Expanded(child: editor), + SizedBox(width: paneWidth, child: panel), + ], + ) + : Column( + children: [ + Expanded(child: editor), + SizedBox(height: paneHeight, child: panel), + ], + ); }, ), ), @@ -974,6 +1070,379 @@ class BusyMarkWysiwygEditorState extends State { ); } + Widget _writersidePropertiesPanel() { + final document = _documentController.document; + final active = _activeBlockId; + final path = active == null + ? const [] + : _documentController.elementPath(active); + final target = _propertiesTargetId == null + ? active == null + ? null + : _documentController.blockById(active) + : _documentController.blockById(_propertiesTargetId!); + final guard = _captureDialogTarget(); + final id = _documentId; + bool current() => + id == _documentId && + _isDialogTargetCurrent(guard) && + (target == null || + identical(_documentController.blockById(target.id), target)); + bool commit(bool Function() mutate) { + if (!current() || DocumentReadOnlyScope.of(context)) return false; + final before = _historySnapshot(); + if (!mutate()) return false; + _recordUndoSnapshot(before); + _emitMarkdown(); + return true; + } + + BusyInlineStyleRange? inline; + if (target != null && target.id == active) { + final selection = _textControllers[active]?.selection; + if (selection?.isValid == true) { + inline = busyInlineReferenceRanges(target.inlines) + .where( + (r) => + (r.kind == BusyInlineKind.writersideVariable || + r.kind == BusyInlineKind.writersideShortcut) && + selection!.start >= r.start && + selection.end <= r.end, + ) + .firstOrNull; + } + } + final inlineTarget = inline; + return BusyMarkWritersideProperties( + key: ValueKey( + 'writerside-properties-$_documentId-$_propertiesDocumentRevision', + ), + document: document, + path: path, + target: target, + topicSelected: _topicPropertiesSelected, + onClose: () => setState(() { + _propertiesVisible = false; + _videoDraftTarget = null; + }), + onTargetSelected: (value) => setState(() { + _topicPropertiesSelected = value == '@topic'; + _propertiesTargetId = _topicPropertiesSelected ? null : value; + _videoDraftTarget = null; + }), + onProperty: (name, value) => commit(() { + if (target == null) return false; + if (name == 'admonition-type') { + final style = busyAdmonitionStyleFromName(value); + if (style == null || + !busyMarkWysiwygCanApplyAdmonitionStyle(target)) { + return false; + } + _documentController.applyAdmonitionStyle(target.id, style); + return true; + } + return _documentController.updateWritersideProperty( + target.id, + name, + value, + ); + }), + onHeadingText: (value) => commit(() { + if (target == null || + value.trim().isEmpty || + target.plainText == value) { + return false; + } + _documentController.updateBlockText(target.id, value); + return true; + }), + onTopicProperty: (name, value) => commit( + () => name == 'switcher-label' + ? _documentController.updateTopicSwitcherLabel(value) + : _documentController.updateTopicNavigation(name, value), + ), + onManageItem: (itemId, direction, remove) => commit( + () => + target != null && + _documentController.manageWritersideItem( + target.id, + itemId: itemId, + direction: direction, + remove: remove, + title: target.attributes['element'] == 'tabs' + ? context.l10n.tab + : context.l10n.wsTerm, + ), + ), + inlineTarget: inlineTarget, + onInlineProperty: (name, value) => commit( + () => + target != null && + inlineTarget != null && + _documentController.updateInlineReference( + target.id, + inlineTarget, + name, + value, + ), + ), + videoDraft: _videoDraftTarget != null, + onInsertVideo: (value) => commit(() { + final draft = _videoDraftTarget; + if (draft == null || + !value.trim().isNotEmpty || + !_documentController.canInsertWriterside( + BusyWritersideInsertCommand.video, + draft, + )) { + return false; + } + final inserted = _documentController.insertWriterside( + BusyWritersideInsertCommand.video, + draft, + title: context.l10n.video, + attributes: {'src': value.trim()}, + ); + if (inserted == null) return false; + _videoDraftTarget = null; + _propertiesTargetId = inserted; + return true; + }), + ); + } + + Future _applyWritersideInsert( + BusyWritersideInsertCommand command, + _WysiwygDialogTarget guard, + String? blockId, + ) async { + if (!_isDialogTargetCurrent(guard) || + !_documentController.canInsertWriterside(command, blockId)) { + return; + } + final target = _documentController.blockById(blockId!)!; + final capturedId = _documentId; + final capturedSource = _documentController.markdown; + final selection = _textControllers[blockId]?.selection; + final index = widget.writersideProjectIndex; + final moduleId = widget.writersideModuleId; + bool current() => + capturedId == _documentId && + _isDialogTargetCurrent(guard) && + identical(widget.writersideProjectIndex, index) && + widget.writersideModuleId == moduleId && + _documentController.markdown == capturedSource && + identical(_documentController.blockById(blockId), target); + var attributes = {}; + if (command == BusyWritersideInsertCommand.video) { + setState(() { + _videoDraftTarget = blockId; + _propertiesVisible = true; + _topicPropertiesSelected = false; + _propertiesTargetId = blockId; + }); + _restoreEditingFocusAfterFrame(); + return; + } + if (command == BusyWritersideInsertCommand.include) { + if (index == null || moduleId == null) return; + final symbols = index.symbols + .where( + (s) => + s.moduleId == moduleId && + (s.kind == WritersideSymbolKind.element || + s.kind == WritersideSymbolKind.snippet) && + s.filePath != _documentController.document.filePath && + index.modulesById[moduleId]?.topics + .where((t) => t.filePath == s.filePath) + .firstOrNull + ?.document + .elementById(s.name) + ?.name != + 'topic', + ) + .toList(); + final files = {for (final s in symbols) s.filePath}.toList()..sort(); + final source = await showBusyMarkMenu( + context: context, + focusFirst: true, + items: [ + for (final file in files) + BusyMarkPopupMenuItem(value: file, label: p.basename(file)), + ], + ); + if (source == null || !current() || !mounted) return; + final candidates = symbols.where((s) => s.filePath == source).toList(); + final element = await showBusyMarkMenu( + context: context, + focusFirst: true, + items: [ + for (final candidate in candidates) + BusyMarkPopupMenuItem(value: candidate, label: candidate.name), + ], + ); + if (element == null || !current()) return; + final topic = index.modulesById[moduleId]?.topics + .where((t) => t.filePath == source) + .firstOrNull; + if (topic == null) return; + attributes = {'from': topic.fileName, 'element-id': element.name}; + } else if (command == BusyWritersideInsertCommand.variable) { + if (index == null || moduleId == null) return; + final names = _documentController.availableVariableReferences( + index, + moduleId, + blockId, + ); + final name = await showBusyMarkMenu( + context: context, + focusFirst: true, + items: [ + for (final name in names) + BusyMarkPopupMenuItem(value: name, label: name), + ], + ); + if (name == null || !current()) return; + final before = _historySnapshot(); + _documentController.insertVariableReference( + blockId, + name, + selection?.isValid == true ? selection!.start : target.plainText.length, + selection?.isValid == true ? selection!.end : target.plainText.length, + ); + _recordUndoSnapshot(before); + _emitMarkdown(); + _restoreEditingFocusAfterFrame(); + return; + } + if (!current() || !mounted) return; + final before = _historySnapshot(); + final inserted = _documentController.insertWriterside( + command, + blockId, + title: busyMarkWritersideInsertLabel(context, command), + attributes: attributes, + ); + if (inserted == null) return; + _recordUndoSnapshot(before); + _emitMarkdown(); + setState(() { + _propertiesVisible = true; + _topicPropertiesSelected = false; + _propertiesTargetId = inserted; + }); + final block = _documentController.blockById(inserted); + final content = block == null + ? null + : _firstEditableBlockIn(_editorRenderEntries(block.children)); + if (content != null) { + _focusBlockAfterFrame(content.id, offset: 0); + } else { + _restoreEditingFocusAfterFrame(); + } + } + + Widget _buildStructuredEntry( + BuildContext context, + _EditorRenderEntry entry, { + required BusyMarkDocumentLayoutSpec documentLayout, + required Set selectedBlockIds, + required Map + selectionRangesByBlockId, + }) { + final block = entry.block; + final tag = block.attributes['element']; + final title = WritersideEditingAdapter.titleElements.contains(tag); + final parent = _documentController + .elementPath(block.id) + .reversed + .skip(1) + .firstOrNull; + final collapsible = + tag != 'deflist' && busyMarkWritersideIsCollapsible(block.attributes) || + tag == 'def' && + parent?.attributes['element'] == 'deflist' && + busyMarkWritersideIsCollapsible(parent!.attributes); + final expanded = + !collapsible || + (_collapsibleExpansion[block.id] ?? + busyMarkWritersideInitiallyExpanded( + tag == 'def' && !block.attributes.containsKey('default-state') + ? parent?.attributes ?? block.attributes + : block.attributes, + )); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + if (collapsible) + BusyMarkHeaderIconButton( + icon: expanded + ? BusyMarkGlyphs.downArrow + : BusyMarkGlyphs.upArrow, + tooltip: expanded + ? context.l10n.collapseKind( + writersideElementLabel(context, block), + ) + : context.l10n.expandKind( + writersideElementLabel(context, block), + ), + onPressed: () => + setState(() => _collapsibleExpansion[block.id] = !expanded), + ), + Expanded( + child: title + ? _buildEditableBlockField( + block, + first: false, + listRunEnd: false, + selectedBlockIds: selectedBlockIds, + selectionRangesByBlockId: selectionRangesByBlockId, + ) + : Text( + writersideElementLabel(context, block), + style: busyMarkSectionHeaderStyle(context), + ), + ), + BusyMarkHeaderIconButton( + icon: BusyMarkGlyphs.settings, + tooltip: context.l10n.wsElementProperties, + onPressed: () => setState(() { + _propertiesTargetId = block.id; + _propertiesVisible = true; + _topicPropertiesSelected = false; + }), + ), + ], + ), + if (expanded) + Padding( + padding: EdgeInsetsDirectional.only( + start: tag == 'topic' || tag == 'chapter' + ? 0 + : BusyMarkSpacing.md, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (final child + in entry.children ?? const <_EditorRenderEntry>[]) + _buildRenderEntry( + context, + child, + documentLayout: documentLayout, + applyDocumentFrame: false, + selectedBlockIds: selectedBlockIds, + selectionRangesByBlockId: selectionRangesByBlockId, + ), + ], + ), + ), + ], + ); + } + BusyMarkDocumentLayoutSpec get _documentLayout => widget.documentLayout ?? BusyMarkDocumentLayoutSpec.standalone.withEditingToolbar( @@ -996,7 +1465,15 @@ class BusyMarkWysiwygEditorState extends State { block, fallback: Directionality.of(context), ); - final content = entry.collapsible + final content = busyMarkIsWritersideContainer(block) + ? _buildStructuredEntry( + context, + entry, + documentLayout: documentLayout, + selectedBlockIds: selectedBlockIds, + selectionRangesByBlockId: selectionRangesByBlockId, + ) + : entry.collapsible ? _buildCollapsibleEntry( context, entry, @@ -1475,6 +1952,13 @@ class BusyMarkWysiwygEditorState extends State { String targetId, TextEditingController controller, ) { + if (_documentController.document.isWriterside && + _activeBlockId == targetId && + mounted) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) setState(() {}); + }); + } if (_cellSelectAllTarget == targetId) _cellSelectAllTarget = null; _scheduleSessionReport(); final continuous = _continuousTextEdit; @@ -1604,6 +2088,41 @@ class BusyMarkWysiwygEditorState extends State { var index = 0; while (index < visibleBlocks.length) { final block = visibleBlocks[index]; + if (busyMarkIsWritersideContainer(block)) { + if ({'topic', 'chapter'}.contains(block.attributes['element']) && + !busyMarkWritersideIsCollapsible(block.attributes)) { + entries.add( + _EditorRenderEntry.blockquote( + block: block, + depth: depth, + children: const [], + ), + ); + entries.addAll(_editorRenderEntries(block.children, depth)); + index++; + continue; + } + entries.add( + _EditorRenderEntry.blockquote( + block: block, + depth: depth, + collapsible: + block.attributes['element'] != 'deflist' && + (busyMarkWritersideIsCollapsible(block.attributes) || + block.attributes['element'] == 'def' && + _documentController + .elementPath(block.id) + .any( + (b) => + b.attributes['element'] == 'deflist' && + busyMarkWritersideIsCollapsible(b.attributes), + )), + children: _editorRenderEntries(block.children), + ), + ); + index++; + continue; + } if (_isWritersideCollapsibleHeading(block)) { final level = int.tryParse(block.attributes['level'] ?? '') ?? 6; var end = index + 1; @@ -1690,7 +2209,9 @@ class BusyMarkWysiwygEditorState extends State { if (block.isSourceProtected) { return false; } - return _isListItemBlock(block) || block.kind == BusyBlockKind.blockquote; + return busyMarkIsWritersideContainer(block) || + _isListItemBlock(block) || + block.kind == BusyBlockKind.blockquote; } bool _isListItemBlock(BusyBlock block) => switch (block.kind) { @@ -1700,21 +2221,45 @@ class BusyMarkWysiwygEditorState extends State { _ => false, }; - void _setActiveBlock(String blockId) { + void _setActiveBlock(String blockId, {bool selectElement = false}) { final changed = _activeBlockId != blockId || _activeCellId != null; + final propertiesChanged = + selectElement && + (_propertiesTargetId != null || + _topicPropertiesSelected || + _videoDraftTarget != null); _activeBlockId = blockId; _activeCellId = null; - if (changed && mounted) { + if (changed || propertiesChanged) { + _propertiesTargetId = null; + _videoDraftTarget = null; + _topicPropertiesSelected = false; + } + if ((changed || propertiesChanged) && mounted) { setState(() {}); } _scheduleSessionReport(); } - void _setActiveTableCell(String tableBlockId, String cellId) { + void _setActiveTableCell( + String tableBlockId, + String cellId, { + bool selectElement = false, + }) { final changed = _activeBlockId != tableBlockId || _activeCellId != cellId; + final propertiesChanged = + selectElement && + (_propertiesTargetId != null || + _topicPropertiesSelected || + _videoDraftTarget != null); _activeBlockId = tableBlockId; _activeCellId = cellId; - if (changed && mounted) { + if (changed || propertiesChanged) { + _propertiesTargetId = null; + _videoDraftTarget = null; + _topicPropertiesSelected = false; + } + if ((changed || propertiesChanged) && mounted) { setState(() {}); } _scheduleSessionReport(); @@ -1727,13 +2272,13 @@ class BusyMarkWysiwygEditorState extends State { } if (_preserveSelectionFocusCallbacks > 0 && _hasBlockSelection) { _preserveSelectionFocusCallbacks--; - _setActiveBlock(tableBlockId); + _setActiveBlock(tableBlockId, selectElement: true); return; } _cellSelectAllTarget = null; _clearBlockSelection(); _collapseFieldSelections(exceptBlockId: cellId); - _setActiveTableCell(tableBlockId, cellId); + _setActiveTableCell(tableBlockId, cellId, selectElement: true); } KeyEventResult _handleDocumentSelectionKeyEvent( @@ -1885,12 +2430,12 @@ class BusyMarkWysiwygEditorState extends State { } if (_preserveSelectionFocusCallbacks > 0 && _hasBlockSelection) { _preserveSelectionFocusCallbacks--; - _setActiveBlock(blockId); + _setActiveBlock(blockId, selectElement: true); return; } _clearBlockSelection(); _collapseInactiveFieldSelections(blockId); - _setActiveBlock(blockId); + _setActiveBlock(blockId, selectElement: true); } void _handleBlockTextChanged( @@ -2572,14 +3117,19 @@ class BusyMarkWysiwygEditorState extends State { final candidates = <_WysiwygSourceTarget>[]; BusyDocument sourceDocument; try { - sourceDocument = const MarkdownParser() - .parse( - filePath: _documentController.document.filePath, - source: source, - mode: _documentController.document.mode, - validateLocalReferences: false, - ) - .busyDocument; + sourceDocument = _documentController.document.isXmlTopic + ? const WritersideEditingAdapter().parseXml( + filePath: _documentController.document.filePath, + source: source, + )! + : const MarkdownParser() + .parse( + filePath: _documentController.document.filePath, + source: source, + mode: _documentController.document.mode, + validateLocalReferences: false, + ) + .busyDocument; } on Object { sourceDocument = _documentController.document; } @@ -4004,6 +4554,15 @@ class BusyMarkWysiwygEditorState extends State { bool _isFocusableTextBlock(BusyBlock block) { return !block.preserveRaw && + (!busyMarkIsWritersideContainer(block) || + WritersideEditingAdapter.titleElements.contains( + block.attributes['element'], + )) && + !{ + 'include', + 'video', + 'show-structure', + }.contains(block.attributes['element']) && block.kind != BusyBlockKind.thematicBreak && block.kind != BusyBlockKind.table; } @@ -4016,7 +4575,10 @@ class BusyMarkWysiwygEditorState extends State { final block = blockId == null ? null : _documentController.blockById(blockId); - return block != null && _isFocusableTextBlock(block); + return block != null && + _isFocusableTextBlock(block) && + (!_documentController.document.isXmlTopic || + block.kind == BusyBlockKind.paragraph); } ({ @@ -4071,7 +4633,7 @@ class BusyMarkWysiwygEditorState extends State { : [if (active != null) active]; return targets.isNotEmpty && targets.every( - (block) => busyMarkWysiwygCanApplyBlockCommand(block, command), + (block) => _documentController.canApplyBlockCommand(block, command), ); } @@ -4116,8 +4678,9 @@ class BusyMarkWysiwygEditorState extends State { } void _restoreEditingFocusAfterFrame() { + final documentId = _documentId; WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) { + if (!mounted || _documentId != documentId) { return; } if (_documentSelection != null) { @@ -4154,7 +4717,7 @@ class BusyMarkWysiwygEditorState extends State { : [if (activeBlock != null) activeBlock]; if (commandTargets.isEmpty || !commandTargets.every( - (block) => busyMarkWysiwygCanApplyBlockCommand(block, command), + (block) => _documentController.canApplyBlockCommand(block, command), )) { return; } @@ -4513,7 +5076,7 @@ class BusyMarkWysiwygEditorState extends State { ); if (!_isDialogTargetCurrent(target) || destination == null || - destination.trim().isEmpty) { + destination.href.trim().isEmpty) { return; } _recordUndoSnapshot(); @@ -4523,7 +5086,10 @@ class BusyMarkWysiwygEditorState extends State { BusyWysiwygInlineCommand.link, range.start, range.end, - destination: destination.trim(), + destination: destination.href.trim(), + linkAttributes: destination.origin == null + ? const {} + : {'origin': destination.origin!}, ); } _clearBlockSelection(); @@ -4569,7 +5135,7 @@ class BusyMarkWysiwygEditorState extends State { ); if (!_isDialogTargetCurrent(dialogTarget) || destination == null || - destination.trim().isEmpty) { + destination.href.trim().isEmpty) { return; } _recordUndoSnapshot(); @@ -4578,7 +5144,10 @@ class BusyMarkWysiwygEditorState extends State { BusyWysiwygInlineCommand.link, rangeToApply.start, rangeToApply.end, - destination: destination.trim(), + destination: destination.href.trim(), + linkAttributes: destination.origin == null + ? const {} + : {'origin': destination.origin!}, ); _emitMarkdown(); } @@ -4627,6 +5196,7 @@ class BusyMarkWysiwygEditorState extends State { } void _applyInlineMathCommand() { + if (_documentController.document.isXmlTopic) return; final target = _activeTextTarget(); if (target == null) { return; @@ -4681,6 +5251,7 @@ class BusyMarkWysiwygEditorState extends State { } void _applyDisplayMathCommand() { + if (_documentController.document.isXmlTopic) return; final blockId = _activeBlockId; if (blockId == null) { return; @@ -5335,6 +5906,7 @@ class BusyMarkWysiwygEditorState extends State { } Future _applyTableCommand() async { + if (_documentController.document.isXmlTopic) return; final blockId = _activeBlockId; if (blockId == null) { return; @@ -5382,6 +5954,7 @@ class BusyMarkWysiwygEditorState extends State { } Future _applyHtmlCommand() async { + if (_documentController.document.isXmlTopic) return; final blockId = _activeBlockId; if (blockId == null) { return; @@ -5528,6 +6101,16 @@ class BusyMarkWysiwygEditorState extends State { return; } final firstBlock = _documentController.blockById(blockIds.first); + if (_documentController.document.isWriterside && + blockIds.length == 1 && + firstBlock?.kind == BusyBlockKind.codeBlock) { + setState(() { + _propertiesVisible = true; + _propertiesTargetId = firstBlock!.id; + _topicPropertiesSelected = false; + }); + return; + } final target = _captureDialogTarget(); final language = await _showCodeLanguageDialog( context, @@ -5573,19 +6156,22 @@ class BusyMarkWysiwygEditorState extends State { ); } - Future _showLinkDialog( + Future _showLinkDialog( BuildContext context, { String initialDestination = '', }) { + if (widget.onChooseLink != null) { + return widget.onChooseLink!(initialDestination); + } var destination = initialDestination; - return _showEditorDialog( + return _showEditorDialog( context, builder: (context) => BusyMarkModalEditorScaffold( title: context.l10n.link, cancelLabel: context.l10n.cancel, saveLabel: context.l10n.apply, onCancel: () => Navigator.pop(context), - onSave: () => Navigator.pop(context, destination), + onSave: () => Navigator.pop(context, LinkDestination(destination)), children: [ BusyMarkGroupedList( filled: true, @@ -5602,7 +6188,8 @@ class BusyMarkWysiwygEditorState extends State { fontFamilyFallback: BusyMarkTypography.monoFontFamilyFallback, ), hintText: 'https://example.com', - onSubmitted: (value) => Navigator.pop(context, value), + onSubmitted: (value) => + Navigator.pop(context, LinkDestination(value)), ), ], ), @@ -6045,6 +6632,7 @@ class BusyMarkWysiwygEditorState extends State { } _internalChange = true; final markdown = _documentController.markdown; + _documentController.rebaseCommittedSource(markdown); widget.onDocumentChanged?.call( _documentController.document.copyWith(source: markdown), ); @@ -6147,14 +6735,16 @@ class BusyMarkWysiwygEditorState extends State { return null; } final liveBlocks = _editableBlocks(_documentController.document.blocks); - final parsedDocument = const MarkdownParser() - .parse( - filePath: _documentController.document.filePath, - source: source, - mode: _documentController.document.mode, - validateLocalReferences: false, - ) - .busyDocument; + final parsedDocument = busyMarkWysiwygBindMarkdownReferences( + const MarkdownParser() + .parse( + filePath: _documentController.document.filePath, + source: source, + mode: _documentController.document.mode, + validateLocalReferences: false, + ) + .busyDocument, + ); final parsedBlocks = _editableBlocks(parsedDocument.blocks); final ranges = _currentSelectionRanges(); if (ranges.isEmpty) { @@ -6297,6 +6887,14 @@ class BusyMarkWysiwygEditorState extends State { required bool endBoundary, String? visibleText, }) { + if (_documentController.document.isXmlTopic) { + return const WritersideEditingAdapter().xmlTextSourceOffset( + _documentController.document, + block, + visibleOffset, + endBoundary: endBoundary, + ); + } final span = sourceSpan ?? block.sourceSpan; if (span == null || span.startOffset < 0 || @@ -8231,8 +8829,8 @@ class _EditorRenderEntry { required this.block, required this.depth, required this.children, - }) : listRunEnd = false, - collapsible = false; + this.collapsible = false, + }) : listRunEnd = false; const _EditorRenderEntry.collapsible({ required this.block, diff --git a/lib/src/editor/wysiwyg/wysiwyg_inline_controller.dart b/lib/src/editor/wysiwyg/wysiwyg_inline_controller.dart index e297ec9c..c67f0e3b 100644 --- a/lib/src/editor/wysiwyg/wysiwyg_inline_controller.dart +++ b/lib/src/editor/wysiwyg/wysiwyg_inline_controller.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart'; import '../../app/busymark_design.dart'; import '../../markdown/busymark_document.dart'; @@ -166,6 +167,34 @@ class BusyInlineSemanticAnchor { final BusyInline inline; } +/// Authored references with no label (for example a keymap shortcut) still +/// have an editable target at their source position. +List busyInlineReferenceRanges(List inlines) { + final ranges = busyInlineStyleRanges(inlines); + var offset = 0; + void visit(BusyInline inline) { + if (inline.plainText.isEmpty && + inline.kind == BusyInlineKind.writersideShortcut) { + ranges.add( + BusyInlineStyleRange( + start: offset, + end: offset, + kind: inline.kind, + attributes: inline.attributes, + ), + ); + } + if (inline.children.isNotEmpty) { + inline.children.forEach(visit); + } else { + offset += inline.plainText.length; + } + } + + inlines.forEach(visit); + return ranges; +} + List busyInlineSemanticAnchors( List inlines, ) { @@ -304,7 +333,10 @@ class BusyMarkWysiwygTextController extends TextEditingController { BusyInlineKind.strikethrough => baseStyle.copyWith( decoration: TextDecoration.lineThrough, ), - BusyInlineKind.code => baseStyle.copyWith( + BusyInlineKind.code || + BusyInlineKind.writersidePath || + BusyInlineKind.writersideUiPath || + BusyInlineKind.writersideShortcut => baseStyle.copyWith( fontFamily: BusyMarkTypography.monoFontFamily, backgroundColor: colors.control, ), @@ -312,6 +344,9 @@ class BusyMarkWysiwygTextController extends TextEditingController { color: colorScheme.primary, decoration: TextDecoration.underline, ), + BusyInlineKind.writersideControl => baseStyle.copyWith( + fontWeight: FontWeight.w600, + ), BusyInlineKind.image => baseStyle.copyWith( color: colorScheme.primary, fontStyle: FontStyle.italic, @@ -446,15 +481,17 @@ class _InheritedInlineStyle { } bool _styledKind(BusyInlineKind kind) { - return { - BusyInlineKind.strong, - BusyInlineKind.emphasis, - BusyInlineKind.underline, - BusyInlineKind.strikethrough, - BusyInlineKind.code, - BusyInlineKind.link, - BusyInlineKind.image, - }.contains(kind); + return busyMarkIsSemanticInline(kind) || + kind == BusyInlineKind.writersideVariable || + { + BusyInlineKind.strong, + BusyInlineKind.emphasis, + BusyInlineKind.underline, + BusyInlineKind.strikethrough, + BusyInlineKind.code, + BusyInlineKind.link, + BusyInlineKind.image, + }.contains(kind); } bool _inlineStyleRangesEqual( @@ -473,7 +510,8 @@ bool _inlineStyleRangesEqual( if (left.start != right.start || left.end != right.end || left.kind != right.kind || - left.destination != right.destination) { + left.destination != right.destination || + !mapEquals(left.attributes, right.attributes)) { return false; } } diff --git a/lib/src/editor/wysiwyg/wysiwyg_toolbar.dart b/lib/src/editor/wysiwyg/wysiwyg_toolbar.dart index 554b3d7c..2185ee92 100644 --- a/lib/src/editor/wysiwyg/wysiwyg_toolbar.dart +++ b/lib/src/editor/wysiwyg/wysiwyg_toolbar.dart @@ -7,11 +7,30 @@ import '../../app/localization.dart'; import '../../markdown/busymark_document.dart'; import 'wysiwyg_commands.dart'; +String busyMarkWritersideInsertLabel( + BuildContext context, + BusyWritersideInsertCommand command, +) => switch (command) { + BusyWritersideInsertCommand.procedure => context.l10n.procedure, + BusyWritersideInsertCommand.tabs => context.l10n.tabs, + BusyWritersideInsertCommand.definitionList => context.l10n.wsDefinitionList, + BusyWritersideInsertCommand.include => context.l10n.wsIncludeContent, + BusyWritersideInsertCommand.variable => context.l10n.wsVariableReference, + BusyWritersideInsertCommand.video => context.l10n.video, + BusyWritersideInsertCommand.tldr => context.l10n.wsTldr, + BusyWritersideInsertCommand.convertListToProcedure => + context.l10n.wsConvertList, +}; + class BusyMarkWysiwygToolbar extends StatelessWidget { const BusyMarkWysiwygToolbar({ super.key, required this.onBlockCommand, this.onAdmonitionCommand, + this.onWritersideInsert, + this.isWritersideInsertEnabled, + this.onProperties, + this.onMenuCanceled, required this.onInlineCommand, required this.onLinkCommand, required this.onInlineMathCommand, @@ -36,14 +55,18 @@ class BusyMarkWysiwygToolbar extends StatelessWidget { final ValueChanged onBlockCommand; final ValueChanged? onAdmonitionCommand; + final ValueChanged? onWritersideInsert; + final bool Function(BusyWritersideInsertCommand)? isWritersideInsertEnabled; + final VoidCallback? onProperties; + final VoidCallback? onMenuCanceled; final ValueChanged onInlineCommand; final VoidCallback onLinkCommand; - final VoidCallback onInlineMathCommand; - final VoidCallback onDisplayMathCommand; + final VoidCallback? onInlineMathCommand; + final VoidCallback? onDisplayMathCommand; final VoidCallback onImageCommand; final VoidCallback onInlineImageCommand; - final VoidCallback onTableCommand; - final VoidCallback onHtmlCommand; + final VoidCallback? onTableCommand; + final VoidCallback? onHtmlCommand; final VoidCallback onIndentCommand; final VoidCallback onOutdentCommand; final VoidCallback onToggleTaskCommand; @@ -63,7 +86,7 @@ class BusyMarkWysiwygToolbar extends StatelessWidget { return SingleChildScrollView( scrollDirection: axis, reverse: alignEnd, - clipBehavior: Clip.none, + clipBehavior: Clip.hardEdge, hitTestBehavior: HitTestBehavior.deferToChild, padding: axis == Axis.horizontal ? const EdgeInsets.symmetric( @@ -158,9 +181,20 @@ class BusyMarkWysiwygToolbar extends StatelessWidget { : null, ), ], + if (admonitionsEnabled) + [ + if (onAdmonitionCommand != null) _admonitionMenu(context), + if (onWritersideInsert != null) _writersideInsertMenu(context), + _writersideSemanticMenu(context), + if (onProperties != null) + _button( + context, + tooltip: context.l10n.wsElementProperties, + icon: BusyMarkGlyphs.settings, + onPressed: onProperties, + ), + ], [ - if (admonitionsEnabled && onAdmonitionCommand != null) - _admonitionMenu(context), _button( context, tooltip: context.l10n.blockquote, @@ -286,6 +320,60 @@ class BusyMarkWysiwygToolbar extends StatelessWidget { return isBlockCommandEnabled?.call(command) ?? true; } + Widget _writersideInsertMenu(BuildContext context) => + BusyMarkHeaderPopupMenuButton( + onCanceled: onMenuCanceled, + key: const ValueKey('wysiwyg-writerside-insert'), + tooltip: '${context.l10n.writerside} · ${context.l10n.insert}', + icon: BusyMarkGlyphs.insertObject, + transparent: false, + elevated: true, + foregroundColor: BusyMarkLinuxPalette.white, + backgroundColor: _editorToolbarButtonBackground(context), + itemBuilder: (context) => [ + for (final command in BusyWritersideInsertCommand.values) + if (command != BusyWritersideInsertCommand.convertListToProcedure || + (isWritersideInsertEnabled?.call(command) ?? false)) + BusyMarkPopupMenuItem( + value: command, + label: busyMarkWritersideInsertLabel(context, command), + enabled: isWritersideInsertEnabled?.call(command) ?? true, + ), + ], + onSelected: onWritersideInsert!, + ); + + Widget _writersideSemanticMenu(BuildContext context) => + BusyMarkHeaderPopupMenuButton( + onCanceled: onMenuCanceled, + key: const ValueKey('wysiwyg-writerside-semantic'), + tooltip: context.l10n.wsSemanticFormats, + icon: BusyMarkGlyphs.code, + transparent: false, + elevated: true, + foregroundColor: BusyMarkLinuxPalette.white, + backgroundColor: _editorToolbarButtonBackground(context), + itemBuilder: (context) => [ + for (final command in const [ + BusyWysiwygInlineCommand.uiControl, + BusyWysiwygInlineCommand.filePath, + BusyWysiwygInlineCommand.uiPath, + BusyWysiwygInlineCommand.shortcut, + ]) + BusyMarkPopupMenuItem( + value: command, + enabled: inlineCommandsEnabled, + label: switch (command) { + BusyWysiwygInlineCommand.uiControl => context.l10n.wsUiControl, + BusyWysiwygInlineCommand.filePath => context.l10n.wsFilePath, + BusyWysiwygInlineCommand.uiPath => context.l10n.wsUiPath, + _ => context.l10n.keyboardShortcuts, + }, + ), + ], + onSelected: onInlineCommand, + ); + List _groups(Axis axis, List> groups) { final widgets = []; for (final (groupIndex, group) @@ -306,6 +394,7 @@ class BusyMarkWysiwygToolbar extends StatelessWidget { Widget _blockStyleMenu(BuildContext context) { return BusyMarkHeaderPopupMenuButton( + onCanceled: onMenuCanceled, tooltip: context.l10n.textStyle, icon: BusyMarkGlyphs.font, shortcut: BusyMarkEditorShortcutLabels.textStyle, @@ -370,6 +459,7 @@ class BusyMarkWysiwygToolbar extends StatelessWidget { Widget _admonitionMenu(BuildContext context) { return BusyMarkHeaderPopupMenuButton( + onCanceled: onMenuCanceled, tooltip: context.l10n.admonition, icon: BusyMarkGlyphs.info, transparent: false, diff --git a/lib/src/export/html_document_writer.dart b/lib/src/export/html_document_writer.dart index e0cc378b..48423adf 100644 --- a/lib/src/export/html_document_writer.dart +++ b/lib/src/export/html_document_writer.dart @@ -866,6 +866,11 @@ class HtmlDocumentWriter { ); case BusyInlineKind.strikethrough: node = _el('del', children: nodes); + case BusyInlineKind.writersideControl: + node = _el('strong', children: nodes); + case BusyInlineKind.writersidePath: + case BusyInlineKind.writersideUiPath: + case BusyInlineKind.writersideShortcut: case BusyInlineKind.code: var text = value.text; if (a['shortcut-layouts'] case final layouts?) { diff --git a/lib/src/export/markdown_copy_export_service.dart b/lib/src/export/markdown_copy_export_service.dart index fdaca800..30206568 100644 --- a/lib/src/export/markdown_copy_export_service.dart +++ b/lib/src/export/markdown_copy_export_service.dart @@ -5,7 +5,7 @@ import 'package:crypto/crypto.dart'; import 'package:path/path.dart' as p; import 'package:uuid/uuid.dart'; -import '../assets/document_media_context.dart'; +import '../assets/document_media_resolver.dart'; import '../core/atomic_file_writer.dart'; import '../nextcloud_notes/data/notes_attachment_references.dart'; @@ -20,6 +20,8 @@ class MarkdownCopyExportService { required String destinationPath, required DocumentMediaContext media, bool overwrite = false, + void Function(String reference)? onMissing, + bool Function()? isCancelled, }) async { final occurrences = await scanNotesAttachmentReferences(source); final references = occurrences.map((r) => r.reference).toSet(); @@ -36,6 +38,9 @@ class MarkdownCopyExportService { final assets = Directory(p.join(staging.path, 'assets')); await assets.create(); for (final reference in references) { + if (isCancelled?.call() == true) { + throw const FileSystemException('Export cancelled.'); + } final uri = Uri.tryParse(reference); if (uri?.scheme == 'https' || uri?.scheme == 'http' || @@ -44,6 +49,13 @@ class MarkdownCopyExportService { } final path = await media.resolve(reference); if (path == null) { + if (onMissing != null && + (occurrences.any((r) => r.reference == reference && r.image) || + reference.startsWith('busymark-attachment:') || + RegExp(r'^\.attachments\.\d+/').hasMatch(reference))) { + onMissing(reference); + continue; + } if (reference.startsWith('busymark-attachment:') || RegExp(r'^\.attachments\.\d+/').hasMatch(reference)) { throw const FileSystemException( @@ -59,11 +71,11 @@ class MarkdownCopyExportService { 'The note attachments exceed the export size limit.', ); } - final bytes = await file.readAsBytes(); - totalBytes += bytes.length; + totalBytes += size; + final digest = await sha256.bind(file.openRead()).first; final extension = p.extension(path).toLowerCase(); - final name = '${sha256.convert(bytes)}$extension'; - await File(p.join(assets.path, name)).writeAsBytes(bytes, flush: true); + final name = '$digest$extension'; + await file.copy(p.join(assets.path, name)); final replacement = Uri(pathSegments: [folderName, name]).toString(); replacements[reference] = replacement; } @@ -76,6 +88,9 @@ class MarkdownCopyExportService { await assets.rename(published.path); ownsPublished = true; } + if (isCancelled?.call() == true) { + throw const FileSystemException('Export cancelled.'); + } await const AtomicFileWriter().writeBytes( destination, utf8.encode(exported), diff --git a/lib/src/export/markdown_export_mapper.dart b/lib/src/export/markdown_export_mapper.dart index 070c7262..34c7a718 100644 --- a/lib/src/export/markdown_export_mapper.dart +++ b/lib/src/export/markdown_export_mapper.dart @@ -463,6 +463,10 @@ class MarkdownExportMapper { text: inline.text, children: children, ), + BusyInlineKind.writersideControl || + BusyInlineKind.writersidePath || + BusyInlineKind.writersideUiPath || + BusyInlineKind.writersideShortcut || BusyInlineKind.code => MarkdownExportInline( kind: MarkdownExportInlineKind.code, text: inline.text, diff --git a/lib/src/markdown/busymark_document.dart b/lib/src/markdown/busymark_document.dart index 209ba72e..a5b41522 100644 --- a/lib/src/markdown/busymark_document.dart +++ b/lib/src/markdown/busymark_document.dart @@ -1,6 +1,28 @@ import '../core/diagnostic.dart'; import '../core/source_span.dart'; import 'markdown_model.dart'; +import '../writerside/writerside_document.dart'; + +const busyMarkXmlBindingAttribute = 'busymark-xml-binding'; +const busyMarkWritersideContainerAttribute = 'busymark-writerside-container'; + +bool busyMarkIsWritersideContainer(BusyBlock block) => + block.attributes[busyMarkWritersideContainerAttribute] == 'true'; + +bool busyMarkIsSemanticInline(BusyInlineKind kind) => const { + BusyInlineKind.writersideControl, + BusyInlineKind.writersidePath, + BusyInlineKind.writersideUiPath, + BusyInlineKind.writersideShortcut, +}.contains(kind); + +String? busyMarkSemanticInlineTag(BusyInlineKind kind) => switch (kind) { + BusyInlineKind.writersideControl => 'control', + BusyInlineKind.writersidePath => 'path', + BusyInlineKind.writersideUiPath => 'ui-path', + BusyInlineKind.writersideShortcut => 'shortcut', + _ => null, +}; /// Marks an empty WYSIWYG paragraph that must remain a source blank line. const busyMarkPreserveEmptyParagraphAttribute = 'preserveEmptyParagraph'; @@ -85,6 +107,7 @@ class BusyDocument { this.frontMatter = const {}, this.rawFrontMatter, this.source, + this.authoredXml, }); final String filePath; @@ -96,6 +119,12 @@ class BusyDocument { final String? rawFrontMatter; final String? source; + /// The unresolved authored XML tree. Presentation resolution never owns saves. + final WritersideDocument? authoredXml; + bool get isXmlTopic => authoredXml != null; + bool get isWriterside => + isXmlTopic || mode == MarkdownMode.writersideMarkdown; + BusyDocument copyWith({ String? title, List? blocks, @@ -103,6 +132,7 @@ class BusyDocument { Map? frontMatter, String? rawFrontMatter, String? source, + WritersideDocument? authoredXml, }) { return BusyDocument( filePath: filePath, @@ -113,6 +143,7 @@ class BusyDocument { frontMatter: frontMatter ?? this.frontMatter, rawFrontMatter: rawFrontMatter ?? this.rawFrontMatter, source: source ?? this.source, + authoredXml: authoredXml ?? this.authoredXml, ); } } @@ -215,6 +246,10 @@ enum BusyInlineKind { hardBreak, html, writersideVariable, + writersideControl, + writersidePath, + writersideUiPath, + writersideShortcut, unknown, } diff --git a/lib/src/markdown/busymark_markdown_serializer.dart b/lib/src/markdown/busymark_markdown_serializer.dart index f20b55f2..2735ac88 100644 --- a/lib/src/markdown/busymark_markdown_serializer.dart +++ b/lib/src/markdown/busymark_markdown_serializer.dart @@ -1,5 +1,6 @@ import '../core/source_span.dart'; import 'busymark_document.dart'; +import '../editor/wysiwyg/writerside_editing_adapter.dart'; import 'math_syntax.dart'; class BusyMarkSerializedInlineFragment { @@ -257,6 +258,29 @@ class BusyMarkMarkdownSerializer { block.rawSource != null) { return block.rawSource!; } + if (busyMarkIsWritersideContainer(block)) { + if (block.attributes['busymark-definition-form'] == 'markdown') { + final attrs = WritersideEditingAdapter.sourceAttributes(block); + final prefix = attrs.isEmpty + ? '' + : '{${attrs.entries.map((e) => '${e.key}="${WritersideEditingAdapter.escape(e.value)}"').join(' ')}}\n'; + return prefix + + block.children + .map( + (item) => + '${item.plainText}\n: ${item.children.map(serializeBlock).join('\n\n').replaceAll('\n', '\n ')}', + ) + .join('\n\n'); + } + return const WritersideEditingAdapter().serializeMarkdownElement(block); + } + if ({ + 'include', + 'video', + 'show-structure', + }.contains(block.attributes['element'])) { + return const WritersideEditingAdapter().serializeMarkdownElement(block); + } return switch (block.kind) { BusyBlockKind.heading => _heading(block), BusyBlockKind.paragraph => _inlineMarkdown( @@ -399,6 +423,17 @@ class BusyMarkMarkdownSerializer { if (block.attributes[busyMarkWritersideDefaultStateAttribute] case final state? when state.trim().isNotEmpty) 'default-state="${_attribute(state)}"', + for (final entry in block.attributes.entries) + if (!{ + 'id', + 'level', + 'generatedId', + busyMarkWritersideCollapsibleAttribute, + busyMarkWritersideDefaultStateAttribute, + busyMarkPreserveTextWhitespaceAttribute, + }.contains(entry.key) && + !entry.key.startsWith('busymark-')) + '${entry.key}="${_attribute(entry.value)}"', ]; final suffix = attributes.isEmpty ? '' : ' {${attributes.join(' ')}}'; return '${'#' * level} $text$suffix'; @@ -712,6 +747,14 @@ class BusyMarkMarkdownSerializer { .replaceAll('>', '>'); } + String _semanticInlineOpening(BusyInline inline) { + final attributes = inline.attributes.entries + .where((e) => !e.key.startsWith('xml-')) + .map((e) => ' ${e.key}="${_escapeXmlAttribute(e.value)}"') + .join(); + return '<${busyMarkSemanticInlineTag(inline.kind)}$attributes>'; + } + _InlineSerialization _inlineMarkdownAtTextOffsets( List inlines, { required Set textOffsets, @@ -1047,6 +1090,11 @@ class BusyMarkMarkdownSerializer { BusyInlineKind.softBreak => ' ', BusyInlineKind.hardBreak => ' \n', BusyInlineKind.writersideVariable => '%${inline.text}%', + BusyInlineKind.writersideControl || + BusyInlineKind.writersidePath || + BusyInlineKind.writersideUiPath || + BusyInlineKind.writersideShortcut => + '${_semanticInlineOpening(inline)}$children', BusyInlineKind.html || BusyInlineKind.unknown => inline.text, }; final escapedTextOffsets = rawText @@ -1067,6 +1115,12 @@ class BusyMarkMarkdownSerializer { BusyInlineKind.strong || BusyInlineKind.strikethrough => 2, BusyInlineKind.underline => 3, BusyInlineKind.emphasis => 1, + BusyInlineKind.writersideControl || + BusyInlineKind.writersidePath || + BusyInlineKind.writersideUiPath || + BusyInlineKind.writersideShortcut => _semanticInlineOpening( + inline, + ).length, _ => 0, }; final sourceOffsets = {}; @@ -1082,6 +1136,11 @@ class BusyMarkMarkdownSerializer { BusyInlineKind.underline || BusyInlineKind.strikethrough => openingLength + (childResult?.sourceOffsets[target] ?? 0), + BusyInlineKind.writersideControl || + BusyInlineKind.writersidePath || + BusyInlineKind.writersideUiPath || + BusyInlineKind.writersideShortcut => + openingLength + (childResult?.sourceOffsets[target] ?? target), BusyInlineKind.link => linkOpeningLength + (childResult?.sourceOffsets[target] ?? diff --git a/lib/src/markdown/document_outline.dart b/lib/src/markdown/document_outline.dart index f50242b8..9e26daa4 100644 --- a/lib/src/markdown/document_outline.dart +++ b/lib/src/markdown/document_outline.dart @@ -46,7 +46,14 @@ extension BusyDocumentOutline on BusyDocument { final headings = []; final generatedIdOccurrences = {}; - for (final block in blocks) { + Iterable xmlHeadings(Iterable candidates) sync* { + for (final block in candidates) { + if (block.kind == BusyBlockKind.heading) yield block; + yield* xmlHeadings(block.children); + } + } + + for (final block in isXmlTopic ? xmlHeadings(blocks) : blocks) { if (block.kind != BusyBlockKind.heading) { continue; } diff --git a/lib/src/markdown/markdown_ast_adapter.dart b/lib/src/markdown/markdown_ast_adapter.dart index 3462755c..d27e010f 100644 --- a/lib/src/markdown/markdown_ast_adapter.dart +++ b/lib/src/markdown/markdown_ast_adapter.dart @@ -14,6 +14,7 @@ import 'raw_html_adapter.dart'; import 'raw_html_policy.dart'; import 'writerside_variable_syntax.dart'; import '../writerside/writerside_schema.dart'; +import '../editor/wysiwyg/writerside_editing_adapter.dart'; const _rawHtmlAdapter = RawHtmlAdapter(); @@ -63,7 +64,10 @@ class MarkdownAstAdapter { String? title, bool preserveHtmlSemantics = false, }) { - final frontMatter = _extractFrontMatter(source); + final frontMatter = _extractFrontMatter( + source, + writerside: mode == MarkdownMode.writersideMarkdown, + ); final imageAttributes = _imageAttributeBlocks(source); final markdownSource = frontMatter == null ? source @@ -553,7 +557,36 @@ class MarkdownAstAdapter { ]; } - if (mode == MarkdownMode.writersideMarkdown && _writersideBlockTag(tag)) { + if (mode == MarkdownMode.writersideMarkdown && + (_writersideBlockTag(tag) || + WritersideEditingAdapter.containers.contains(tag))) { + if (WritersideEditingAdapter.containers.contains(tag) && + !_writersideAdmonitionTag(tag)) { + final titled = WritersideEditingAdapter.titleElements.contains(tag); + return [ + BusyBlock( + id: nextId(), + kind: _writersideKind(tag), + inlines: titled + ? [ + BusyInline( + kind: BusyInlineKind.text, + text: node.attributes['title'] ?? '', + ), + ] + : const [], + children: [ + for (final child in children) + ..._blocksFromNode(child, nextId: nextId, mode: mode), + ], + attributes: { + ...node.attributes, + 'element': tag, + busyMarkWritersideContainerAttribute: 'true', + }, + ), + ]; + } if (tag == 'code-block') { return [ _writersideCodeBlock( @@ -928,6 +961,19 @@ class MarkdownAstAdapter { ); final text = node.textContent; final inlines = switch (tag) { + 'control' || 'path' || 'ui-path' || 'shortcut' => [ + BusyInline( + kind: switch (tag) { + 'control' => BusyInlineKind.writersideControl, + 'path' => BusyInlineKind.writersidePath, + 'ui-path' => BusyInlineKind.writersideUiPath, + _ => BusyInlineKind.writersideShortcut, + }, + text: text, + children: children, + attributes: node.attributes, + ), + ], busyMarkMathInlineTag => [ BusyInline( kind: BusyInlineKind.math, @@ -1223,6 +1269,19 @@ class MarkdownAstAdapter { required String Function() nextId, required bool allowVideo, }) { + final semantic = const WritersideEditingAdapter().parseMarkdownElement( + value, + nextId, + ); + if (semantic != null && + !{ + 'tip', + 'note', + 'warning', + 'quote', + }.contains(semantic.attributes['element'])) { + return semantic; + } try { final fragment = XmlDocumentFragment.parse(value.trim()); final elements = fragment.children.whereType().toList(); @@ -1639,7 +1698,8 @@ class MarkdownAstAdapter { } bool _writersideBlockTag(String tag) { - return WritersideSchema.isMarkdownSemanticBlock(tag); + return !{'control', 'path', 'ui-path', 'shortcut'}.contains(tag) && + WritersideSchema.isMarkdownSemanticBlock(tag); } bool _editableWritersideTag(String tag) { @@ -1744,7 +1804,7 @@ class MarkdownAstAdapter { return result; } - _FrontMatter? _extractFrontMatter(String source) { + _FrontMatter? _extractFrontMatter(String source, {required bool writerside}) { final closing = frontMatterClosing(source); if (closing == null) { return null; @@ -1758,9 +1818,11 @@ class MarkdownAstAdapter { r'^\s*([A-Za-z0-9_-]+)\s*:\s*(.+?)\s*$', ).firstMatch(line); if (match != null) { - values[match.group(1)!] = match - .group(2)! - .replaceAll(RegExp(r'''^["']|["']$'''), ''); + final key = match.group(1)!; + final scalar = match.group(2)!; + values[key] = writerside && key == 'switcher-label' + ? decodeWritersideSwitcherLabel(scalar) + : scalar.replaceAll(RegExp(r'''^["']|["']$'''), ''); } } return _FrontMatter(raw: raw, endOffset: end, values: values); diff --git a/lib/src/markdown/markdown_front_matter.dart b/lib/src/markdown/markdown_front_matter.dart index 40dcf6ad..c7123a23 100644 --- a/lib/src/markdown/markdown_front_matter.dart +++ b/lib/src/markdown/markdown_front_matter.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + /// Supported metadata delimiters occupy an entire line. Horizontal whitespace /// and CRLF are accepted, but prefixes such as `---example` are ordinary text. final _delimiter = RegExp(r'^---[ \t]*\r?$', multiLine: true); @@ -17,3 +19,34 @@ int frontMatterEndOffset(String source) { ? closing.end + 1 : closing.end; } + +/// Writerside publishes switcher front matter as literal text. Adding YAML +/// single quotes changes the published label, so ordinary labels stay unquoted. +String encodeWritersideSwitcherLabel(String value) { + if (value.trim() != value || + RegExp(r'[\x00-\x1f\x7f]').hasMatch(value) || + value.startsWith('"') && value.endsWith('"') || + value.startsWith("'") && value.endsWith("'")) { + return jsonEncode(value); + } + return value; +} + +/// Decode only the topic switcher scalar; other metadata and the authored +/// front-matter source retain their existing handling. +String decodeWritersideSwitcherLabel(String scalar) { + if (scalar.length < 2) return scalar; + if (scalar.startsWith("'") && scalar.endsWith("'")) { + return scalar.substring(1, scalar.length - 1).replaceAll("''", "'"); + } + if (scalar.startsWith('"') && scalar.endsWith('"')) { + try { + return jsonDecode(scalar) as String; + } on FormatException { + // Keep the existing display behavior for YAML escape forms outside the + // scalar subset emitted by the authoring control. Source stays intact. + return scalar.substring(1, scalar.length - 1); + } + } + return scalar; +} diff --git a/lib/src/markdown/markdown_parser.dart b/lib/src/markdown/markdown_parser.dart index 09de5320..578fc67d 100644 --- a/lib/src/markdown/markdown_parser.dart +++ b/lib/src/markdown/markdown_parser.dart @@ -758,6 +758,10 @@ class MarkdownParser { case BusyInlineKind.softBreak: case BusyInlineKind.hardBreak: case BusyInlineKind.html: + case BusyInlineKind.writersideControl: + case BusyInlineKind.writersidePath: + case BusyInlineKind.writersideUiPath: + case BusyInlineKind.writersideShortcut: case BusyInlineKind.writersideVariable: case BusyInlineKind.unknown: break; @@ -790,9 +794,35 @@ class MarkdownParser { ) { var updated = block; if (updated.kind != BusyBlockKind.frontMatter && sourceChunk != null) { + // Semantic fragments are parsed independently. Their descendants carry + // fragment-relative spans; bind them to the containing Markdown source. + BusyBlock positionChild(BusyBlock child) { + final span = child.sourceSpan; + final base = sourceChunk.span; + return child.copyWith( + sourceSpan: span == null || span.filePath.isNotEmpty + ? span + : SourceSpan( + filePath: base.filePath, + startOffset: base.startOffset + span.startOffset, + endOffset: base.startOffset + span.endOffset, + startLine: base.startLine + span.startLine - 1, + endLine: base.startLine + span.endLine - 1, + startColumn: + span.startColumn + + (span.startLine == 1 ? base.startColumn - 1 : 0), + endColumn: + span.endColumn + + (span.endLine == 1 ? base.startColumn - 1 : 0), + ), + children: child.children.map(positionChild).toList(), + ); + } + updated = updated.copyWith( rawSource: sourceChunk.rawSource, sourceSpan: sourceChunk.span, + children: updated.children.map(positionChild).toList(), ); if (sourceChunk.protectEdits) { updated = _sourceProtectedTree(updated); diff --git a/lib/src/markdown/markdown_source_map.dart b/lib/src/markdown/markdown_source_map.dart index 17a70094..221d6e03 100644 --- a/lib/src/markdown/markdown_source_map.dart +++ b/lib/src/markdown/markdown_source_map.dart @@ -445,6 +445,8 @@ class BusyMarkInlineParserContext { final md.Document _document; final md.Document _mappingDocument; final Map _referenceDefinitions; + Map get referenceDefinitions => + _referenceDefinitions; final _SourceMappingEmailAutolinkSyntax _emailSyntax; int parseInvocations = 0; diff --git a/lib/src/markdown/math_syntax.dart b/lib/src/markdown/math_syntax.dart index 2c71e493..285d04d4 100644 --- a/lib/src/markdown/math_syntax.dart +++ b/lib/src/markdown/math_syntax.dart @@ -4,6 +4,7 @@ import 'busymark_document.dart'; import 'markdown_model.dart'; import 'markdown_source_annotation.dart'; import 'writerside_variable_syntax.dart'; +import 'writerside_authoring_syntax.dart'; const busyMarkLiteralLessThanTag = 'busymark-literal-less-than'; @@ -36,9 +37,17 @@ md.Document busyMarkMarkdownDocument( Iterable leadingInlineSyntaxes = const [], }) { return md.Document( - blockSyntaxes: const [BusyDisplayMathSyntax()], + blockSyntaxes: [ + const BusyDisplayMathSyntax(), + if (mode == MarkdownMode.writersideMarkdown) + const WritersideSemanticParagraphSyntax(), + if (mode == MarkdownMode.writersideMarkdown) + const WritersideDefinitionListSyntax(), + ], inlineSyntaxes: [ ...leadingInlineSyntaxes, + if (mode == MarkdownMode.writersideMarkdown) + WritersideSemanticInlineSyntax(), _LiteralLessThanSyntax(), if (mode == MarkdownMode.writersideMarkdown) WritersideLiteralPercentSyntax(), diff --git a/lib/src/markdown/preview_model.dart b/lib/src/markdown/preview_model.dart index 5de727ea..857d2768 100644 --- a/lib/src/markdown/preview_model.dart +++ b/lib/src/markdown/preview_model.dart @@ -189,6 +189,13 @@ class BusyMarkPreviewBuilder { } PreviewBlock _block(BusyBlock block, String path) { + if (busyMarkIsWritersideContainer(block) && + block.attributes['element'] == 'chapter' && + block.rawSource != null && + !block.dirty) { + final semantic = _writersideSemanticBlock(block, path); + if (semantic != null) return _withSourceSpan(semantic, block.sourceSpan); + } final preview = switch (block.kind) { BusyBlockKind.heading => PreviewBlock( kind: PreviewBlockKind.heading, @@ -197,7 +204,7 @@ class BusyMarkPreviewBuilder { inlines: _inlines(block.inlines, 'block-${block.id}.i'), children: [ for (final (index, child) in block.children.indexed) - _block(child, '$path.b$index'), + if (!child.isSourceOnly) _block(child, '$path.b$index'), ], attributes: { ...block.attributes, @@ -211,7 +218,7 @@ class BusyMarkPreviewBuilder { inlines: _inlines(block.inlines, 'block-${block.id}.i'), children: [ for (final (index, child) in block.children.indexed) - _block(child, '$path.b$index'), + if (!child.isSourceOnly) _block(child, '$path.b$index'), ], attributes: block.attributes, ), @@ -244,7 +251,7 @@ class BusyMarkPreviewBuilder { inlines: _inlines(block.inlines, 'block-${block.id}.i'), children: [ for (final (index, child) in block.children.indexed) - _block(child, '$path.b$index'), + if (!child.isSourceOnly) _block(child, '$path.b$index'), ], attributes: block.attributes, ), @@ -271,7 +278,7 @@ class BusyMarkPreviewBuilder { text: '', children: [ for (final (index, child) in block.children.indexed) - _block(child, '$path.b$index'), + if (!child.isSourceOnly) _block(child, '$path.b$index'), ], attributes: block.attributes, ), @@ -281,7 +288,7 @@ class BusyMarkPreviewBuilder { text: _plainText(block.inlines), children: [ for (final (index, child) in block.children.indexed) - _block(child, '$path.b$index'), + if (!child.isSourceOnly) _block(child, '$path.b$index'), ], attributes: block.attributes, ), @@ -303,7 +310,7 @@ class BusyMarkPreviewBuilder { text: block.children.map((child) => child.plainText).join('\n'), children: [ for (final (index, child) in block.children.indexed) - _block(child, '$path.b$index'), + if (!child.isSourceOnly) _block(child, '$path.b$index'), ], attributes: block.attributes, ), @@ -427,7 +434,7 @@ class BusyMarkPreviewBuilder { )?.kind; final children = [ for (final (index, child) in block.children.indexed) - _block(child, '$path.b$index'), + if (!child.isSourceOnly) _block(child, '$path.b$index'), ]; return switch (semanticKind) { WritersideSemanticKind.definitionList => PreviewBlock( @@ -566,6 +573,10 @@ class BusyMarkPreviewBuilder { BusyInlineKind.emphasis => PreviewInlineKind.emphasis, BusyInlineKind.underline => PreviewInlineKind.underline, BusyInlineKind.strikethrough => PreviewInlineKind.strikethrough, + BusyInlineKind.writersideControl => PreviewInlineKind.strong, + BusyInlineKind.writersidePath || + BusyInlineKind.writersideUiPath || + BusyInlineKind.writersideShortcut || BusyInlineKind.code => PreviewInlineKind.code, BusyInlineKind.link => PreviewInlineKind.link, BusyInlineKind.image => PreviewInlineKind.image, diff --git a/lib/src/markdown/writerside_authoring_syntax.dart b/lib/src/markdown/writerside_authoring_syntax.dart new file mode 100644 index 00000000..2814bfbc --- /dev/null +++ b/lib/src/markdown/writerside_authoring_syntax.dart @@ -0,0 +1,122 @@ +import 'package:markdown/markdown.dart' as md; +import 'package:xml/xml.dart'; + +/// Writerside semantic spans participate in the existing inline parser, so +/// Markdown formatting and links inside a span retain their normal behavior. +class WritersideSemanticInlineSyntax extends md.InlineSyntax { + WritersideSemanticInlineSyntax() + : super( + r'<(control|path|ui-path|shortcut)\b[^>]*(?:/>|>)', + startCharacter: 0x3c, + ); + @override + bool onMatch(md.InlineParser parser, Match match) { + final tag = match.group(1)!; + var end = match.end; + var content = ''; + if (!match.group(0)!.endsWith('/>')) { + final closing = ''; + final close = parser.source.indexOf(closing, end); + if (close < 0) return false; + content = parser.source.substring(end, close); + end = close + closing.length; + } + try { + final opening = match.group(0)!; + final element = XmlDocumentFragment.parse( + opening.endsWith('/>') + ? opening + : opening.replaceFirst(RegExp(r'>$'), '/>'), + ).children.whereType().single; + parser.addNode( + md.Element(tag, parser.document.parseInline(content)) + ..attributes.addAll({ + for (final a in element.attributes) a.name.qualified: a.value, + }), + ); + parser.consume(end - match.end); + return true; + } on Object { + return false; + } + } +} + +class WritersideSemanticParagraphSyntax extends md.BlockSyntax { + const WritersideSemanticParagraphSyntax(); + @override + RegExp get pattern => RegExp(r'^ {0,3}<(?:control|path|ui-path|shortcut)\b'); + @override + md.Node parse(md.BlockParser parser) { + final lines = []; + while (!parser.isDone && parser.current.content.trim().isNotEmpty) { + lines.add(parser.current.content); + parser.advance(); + } + return md.Element('p', parser.document.parseInline(lines.join('\n'))); + } +} + +/// The documented term / colon form. Multiple definitions and indented body +/// lines stay in their original pair instead of being flattened into prose. +class WritersideDefinitionListSyntax extends md.BlockSyntax { + const WritersideDefinitionListSyntax(); + @override + RegExp get pattern => RegExp(r'^\S'); + @override + bool canParse(md.BlockParser parser) { + if (parser.isDone) return false; + final offset = _attributes(parser.current.content) == null ? 1 : 2; + return parser.peek(offset) != null && + RegExp(r'^:\s+').hasMatch(parser.peek(offset)!.content); + } + + @override + md.Node parse(md.BlockParser parser) { + final attributes = + _attributes(parser.current.content) ?? const {}; + if (attributes.isNotEmpty) parser.advance(); + final definitions = []; + while (canParse(parser)) { + final term = parser.current.content; + parser.advance(); + final body = [ + parser.current.content.replaceFirst(RegExp(r'^:\s+'), ''), + ]; + parser.advance(); + while (!parser.isDone && + RegExp(r'^ {2,}\S').hasMatch(parser.current.content)) { + body.add(parser.current.content.replaceFirst(RegExp(r'^ {2,4}'), '')); + parser.advance(); + } + definitions.add( + md.Element('def', parser.document.parseLines(body)) + ..attributes['title'] = term, + ); + if (!parser.isDone && + parser.current.content.trim().isEmpty && + parser.peek(1) != null && + parser.peek(2) != null && + RegExp(r'^:\s+').hasMatch(parser.peek(2)!.content)) { + parser.advance(); + } + } + return md.Element('deflist', definitions) + ..attributes.addAll({ + ...attributes, + 'busymark-definition-form': 'markdown', + }); + } + + Map? _attributes(String source) { + final value = source.trim(); + if (!value.startsWith('{') || !value.endsWith('}')) return null; + final result = {}; + for (final match in RegExp( + r'''([\w:-]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s}]+))''', + ).allMatches(value.substring(1, value.length - 1))) { + result[match[1]!] = match[2] ?? match[3] ?? match[4]!; + } + return result.isEmpty ? null : result; + } +} diff --git a/lib/src/nextcloud_notes/application/nextcloud_connection.dart b/lib/src/nextcloud_notes/application/nextcloud_connection.dart index caa58e90..88e6148d 100644 --- a/lib/src/nextcloud_notes/application/nextcloud_connection.dart +++ b/lib/src/nextcloud_notes/application/nextcloud_connection.dart @@ -46,30 +46,56 @@ final nextcloudNotesRepositoryProvider = FutureProvider(( final client = ref.watch(nextcloudHttpClientProvider); final secrets = ref.watch(nextcloudSecretStoreProvider); final store = await NotesStore.open(path: path); - final repository = NotesRepository( + Future passwordFor(NextcloudAccount account) async { + String? password; + try { + password = await secrets.read(account.id); + } on NextcloudCredentialException { + throw const NotesException( + NotesFailureCode.authentication, + 'BusyMark could not access the desktop keyring. Unlock it and reconnect Nextcloud.', + scope: NotesRequestScope.account, + ); + } + if (password == null) { + throw const NotesException( + NotesFailureCode.authentication, + 'The Nextcloud app password is unavailable. Reconnect this account.', + scope: NotesRequestScope.account, + ); + } + return password; + } + + late final NotesRepository repository; + repository = NotesRepository( store: store, clientForAccount: (account) async { - String? password; - try { - password = await secrets.read(account.id); - } on NextcloudCredentialException { - throw const NotesException( - NotesFailureCode.authentication, - 'BusyMark could not access the desktop keyring. Unlock the keyring and reconnect Nextcloud.', - ); - } - if (password == null) { - throw const NotesException( - NotesFailureCode.authentication, - 'The Nextcloud app password is unavailable. Reconnect this account.', - ); - } + final password = await passwordFor(account); + final generation = repository.accountGeneration(account.id); + var epoch = repository.capabilityEpoch(account.id); return NotesApiClient( client: client, account: account, appPassword: password, + onApiVersions: (raw) async { + final current = repository.capabilityEpoch(account.id) == epoch; + await repository.recordApiVersions( + account.id, + generation, + epoch, + raw, + ); + if (current) epoch = repository.capabilityEpoch(account.id); + }, ); }, + fetchCapabilities: (account) async => fetchNotesCapabilities( + client: client, + server: account.server, + loginName: account.loginName, + appPassword: await passwordFor(account), + ), ); try { await repository.initialize(); @@ -226,7 +252,7 @@ class NextcloudConnectionController extends Notifier { listEtag: previous?.listEtag, lastModified: previous?.lastModified, ); - await repository.upsertAccount(account); + await repository.upsertAccount(account, reconnect: previous != null); committed = true; if (_disposed) return true; state = NextcloudConnectionState( diff --git a/lib/src/nextcloud_notes/application/notes_media.dart b/lib/src/nextcloud_notes/application/notes_media.dart index 9b7c1cd9..871fb568 100644 --- a/lib/src/nextcloud_notes/application/notes_media.dart +++ b/lib/src/nextcloud_notes/application/notes_media.dart @@ -41,6 +41,7 @@ class NextcloudDocumentMedia { final String accountId; final String localId; final _paths = {}; + final _resolvedAt = {}; final _pending = >{}; DocumentMediaContext? _context; int _version = -1; @@ -64,12 +65,17 @@ class NextcloudDocumentMedia { _version = version; _context = null; _paths.clear(); + _resolvedAt.clear(); _pending.clear(); } Future resolve(String reference) async { _checkVersion(); - if (_paths.containsKey(reference)) return _paths[reference]; + if (_paths.containsKey(reference) && + repository.clock().difference(_resolvedAt[reference]!) < + NotesRepository.mediaFreshness) { + return _paths[reference]; + } if (_pending.containsKey(reference)) return _pending[reference]; final future = _resolve(reference); _pending[reference] = future; @@ -81,12 +87,13 @@ class NextcloudDocumentMedia { } Future _resolve(String reference) async { - final version = _version; try { final path = await repository.resolveMedia(accountId, localId, reference); _checkVersion(); - if (version != _version) return null; - if (path != null) _paths[reference] = path; + if (path != null) { + _paths[reference] = path; + _resolvedAt[reference] = repository.clock(); + } return path; } on Object { // An unavailable remote item is represented by the renderer placeholder. diff --git a/lib/src/nextcloud_notes/application/notes_navigation.dart b/lib/src/nextcloud_notes/application/notes_navigation.dart new file mode 100644 index 00000000..62de3b09 --- /dev/null +++ b/lib/src/nextcloud_notes/application/notes_navigation.dart @@ -0,0 +1,122 @@ +import '../domain/notes_models.dart'; + +enum NotesDestination { all, favorites, uncategorized, category, recovery } + +enum NotesSort { recent, oldest, titleAscending, titleDescending } + +bool isNotesRecovery(NextcloudNote note) => + note.syncState == NoteSyncState.deletedRemotely && !note.hasPendingChanges; +bool categoryIncludes(String parent, String category) => + parent.isEmpty || parent == category || category.startsWith('$parent/'); + +/// Selection lives in stable local identity space, independently of open tabs. +class NotesNavigationController { + NotesDestination destination = NotesDestination.all; + NotesSort sort = NotesSort.recent; + String category = ''; + final collapsed = {}; + final selected = {}; + String? focused; + String? anchor; + List visible = const []; + Map categoryCounts = const {}; + List categoryPaths = const []; + Map counts = const {}; + + void update(Iterable notes, String accountId) { + final scoped = notes.where((n) => n.accountId == accountId).toList(); + final live = scoped.where((n) => !isNotesRecovery(n)).toList(); + final categories = {}; + for (final note in live) { + final segments = note.category.split('/'); + for (var i = 1; i <= segments.length; i++) { + final key = segments.take(i).join('/'); + if (key.isNotEmpty) categories[key] = (categories[key] ?? 0) + 1; + } + } + if (destination == NotesDestination.category && + !categories.containsKey(category)) { + destination = NotesDestination.all; + category = ''; + } + categoryCounts = Map.unmodifiable(categories); + categoryPaths = categories.keys.toList()..sort(); + counts = { + NotesDestination.all: live.length, + NotesDestination.favorites: live.where((n) => n.favorite).length, + NotesDestination.uncategorized: live + .where((n) => n.category.isEmpty) + .length, + NotesDestination.recovery: scoped.where(isNotesRecovery).length, + }; + visible = + scoped + .where( + (n) => switch (destination) { + NotesDestination.recovery => isNotesRecovery(n), + NotesDestination.all => !isNotesRecovery(n), + NotesDestination.favorites => !isNotesRecovery(n) && n.favorite, + NotesDestination.uncategorized => + !isNotesRecovery(n) && n.category.isEmpty, + NotesDestination.category => + !isNotesRecovery(n) && categoryIncludes(category, n.category), + }, + ) + .toList() + ..sort((a, b) { + final order = switch (sort) { + NotesSort.recent => b.activityMicros.compareTo(a.activityMicros), + NotesSort.oldest => a.activityMicros.compareTo(b.activityMicros), + NotesSort.titleAscending => a.title.toLowerCase().compareTo( + b.title.toLowerCase(), + ), + NotesSort.titleDescending => b.title.toLowerCase().compareTo( + a.title.toLowerCase(), + ), + }; + return order == 0 ? a.localId.compareTo(b.localId) : order; + }); + // Hidden selections cannot accidentally become targets after a filter. + selected.retainAll(visible.map((n) => n.localId)); + if (!visible.any((n) => n.localId == focused)) focused = null; + if (!visible.any((n) => n.localId == anchor)) anchor = null; + } + + void select(String id, {bool toggle = false, bool range = false}) { + final ids = visible.map((n) => n.localId).toList(); + if (!ids.contains(id)) return; + focused = id; + if (range && anchor != null && ids.contains(anchor)) { + final a = ids.indexOf(anchor!); + final b = ids.indexOf(id); + if (!toggle) selected.clear(); + selected.addAll(ids.sublist(a < b ? a : b, (a > b ? a : b) + 1)); + } else if (toggle) { + if (!selected.remove(id)) selected.add(id); + anchor = id; + } else { + selected + ..clear() + ..add(id); + anchor = id; + } + } + + void selectAll() => selected.addAll(visible.map((n) => n.localId)); + List contextTargets(String id) => selected.contains(id) + ? visible + .where((n) => selected.contains(n.localId)) + .map((n) => n.localId) + .toList() + : [id]; +} + +enum NotesBatchStatus { changed, skipped, conflicted, failed } + +class NotesBatchOutcome { + const NotesBatchOutcome(this.localId, this.title, this.status, [this.detail]); + final String localId; + final String title; + final NotesBatchStatus status; + final String? detail; +} diff --git a/lib/src/nextcloud_notes/application/notes_offline_controller.dart b/lib/src/nextcloud_notes/application/notes_offline_controller.dart new file mode 100644 index 00000000..95fd185e --- /dev/null +++ b/lib/src/nextcloud_notes/application/notes_offline_controller.dart @@ -0,0 +1,240 @@ +import 'dart:async'; + +import '../domain/notes_models.dart'; +import 'notes_navigation.dart'; +import 'notes_repository.dart'; + +class NotesOfflineStatus { + const NotesOfflineStatus({ + required this.revision, + required this.textAvailable, + required this.requiredCount, + required this.availableCount, + this.missing = const [], + this.external = const [], + this.running = false, + this.paused = false, + }); + final int revision, requiredCount, availableCount; + final bool textAvailable, running, paused; + final List missing, external; + bool get available => textAvailable && requiredCount == availableCount; +} + +/// Local retention requirements drive the existing repository resolver. This +/// owns neither credentials nor another download queue. Failed and cancelled +/// revisions wait for deliberate retry; completed SQLite bytes remain retained. +class NotesOfflineController { + NotesOfflineController(this.repository, this.accountId) { + _subscription = repository.changes.listen((_) => _schedule()); + } + final NotesRepository repository; + final String accountId; + late final StreamSubscription _subscription; + final _changes = StreamController.broadcast(); + Stream get changes => _changes.stream; + List> requirements = []; + final statuses = {}; + final _attempted = {}; + final failures = {}; + Timer? _debounce; + bool _disposed = false, _running = false, _again = false; + int _generation = 0; + Future initialize() async { + requirements = await repository.store.offlineRequirements(accountId); + unawaited(reconcile()); + } + + bool retained(NextcloudNote note) => + requirements.any((r) => _includes(r, note)); + bool individuallyRetained(String id) => + requirements.any((r) => r['kind'] == 'note' && r['target'] == id); + bool categoryRetained(String category) => requirements.any( + (r) => r['kind'] == 'category' && r['target'] == category, + ); + bool _includes(Map r, NextcloudNote note) => + r['kind'] == 'pause' + ? false + : r['kind'] == 'note' + ? r['target'] == note.localId + : categoryIncludes(r['target'] as String, note.category); + bool _active(NextcloudNote note) => + !requirements.any( + (r) => r['kind'] == 'pause' && r['target'] == note.localId, + ) && + requirements.any((r) => r['paused'] == 0 && _includes(r, note)); + Future setRequirement( + String kind, + String target, { + bool required = true, + bool paused = false, + }) async { + await repository.setOfflineRequirement( + accountId, + kind, + target, + required: required, + paused: paused, + ); + requirements = await repository.store.offlineRequirements(accountId); + _generation++; + for (final note in repository.notes.where( + (n) => n.accountId == accountId && !_active(n), + )) { + repository.cancelMediaDownloads(note.localId); + } + if (required && !paused && kind != 'pause') { + for (final note in repository.notes.where( + (n) => + n.accountId == accountId && + (kind == 'note' + ? n.localId == target + : categoryIncludes(target, n.category)), + )) { + _attempted.removeWhere((key) => key.startsWith('${note.localId}:')); + failures.removeWhere((key, _) => key.startsWith('${note.localId}:')); + } + } + await reconcile(); + } + + Future cancelNote(String id) => + setRequirement('pause', id, paused: true); + + Future retry([String? noteId]) async { + for (final r in requirements.toList()) { + if (r['kind'] == 'pause' && (noteId == null || r['target'] == noteId)) { + await repository.setOfflineRequirement( + accountId, + 'pause', + r['target'] as String, + required: false, + ); + } else if (r['paused'] == 1 && + (noteId == null || r['kind'] == 'note' && r['target'] == noteId)) { + await repository.setOfflineRequirement( + accountId, + r['kind'] as String, + r['target'] as String, + ); + } + } + requirements = await repository.store.offlineRequirements(accountId); + _attempted.clear(); + failures.clear(); + await reconcile(); + } + + void _schedule() { + if (_disposed || requirements.isEmpty) return; + _debounce?.cancel(); + _debounce = Timer( + const Duration(milliseconds: 150), + () => unawaited(reconcile()), + ); + } + + Future inspect( + NextcloudNote note, { + String? source, + }) async { + note = repository.noteById(note.localId) ?? note; + final dependencies = await repository.attachmentAvailability( + note.localId, + source: source, + ); + return NotesOfflineStatus( + revision: note.revision, + textAvailable: true, + requiredCount: dependencies.required.length, + availableCount: dependencies.available.length, + missing: dependencies.required + .where((r) => !dependencies.available.contains(r)) + .toList(), + external: dependencies.external, + running: _running && _active(note), + paused: retained(note) && !_active(note), + ); + } + + Future reconcile() async { + if (_disposed) return; + if (_running) { + _again = true; + return; + } + _running = true; + final generation = _generation; + try { + for (final note in repository.notes.where( + (n) => n.accountId == accountId && retained(n) && !isNotesRecovery(n), + )) { + if (_disposed || repository.accountById(accountId) == null) return; + var status = await inspect(note); + statuses[note.localId] = status; + _emit(); + if (_active(note)) { + for (final reference in status.missing) { + if (_disposed || generation != _generation || !_active(note)) break; + final key = '${note.localId}:${note.revision}:$reference'; + if (!_attempted.add(key)) continue; + try { + final path = await repository.resolveMedia( + accountId, + note.localId, + reference, + ); + if (path == null) failures[key] = 'Attachment unavailable'; + } on Object catch (error) { + failures[key] = error.toString(); + } + if (repository.accountById(accountId) == null) return; + final current = repository.noteById(note.localId); + if (current != null) { + status = await inspect(current); + statuses[note.localId] = status; + _emit(); + } + } + } + } + statuses.removeWhere((id, _) => repository.noteById(id) == null); + } finally { + _running = false; + for (final entry in statuses.entries.toList()) { + final s = entry.value; + statuses[entry.key] = NotesOfflineStatus( + revision: s.revision, + textAvailable: s.textAvailable, + requiredCount: s.requiredCount, + availableCount: s.availableCount, + missing: s.missing, + external: s.external, + paused: s.paused, + ); + } + _emit(); + if (_again && !_disposed) { + _again = false; + _schedule(); + } + } + } + + void _emit() { + if (!_disposed) _changes.add(null); + } + + Future dispose() async { + _disposed = true; + _generation++; + _debounce?.cancel(); + for (final note in repository.notes.where( + (n) => n.accountId == accountId && retained(n), + )) { + repository.cancelMediaDownloads(note.localId); + } + await _subscription.cancel(); + await _changes.close(); + } +} diff --git a/lib/src/nextcloud_notes/application/notes_repository.dart b/lib/src/nextcloud_notes/application/notes_repository.dart index be8147e7..36e6b04a 100644 --- a/lib/src/nextcloud_notes/application/notes_repository.dart +++ b/lib/src/nextcloud_notes/application/notes_repository.dart @@ -10,23 +10,43 @@ import 'package:uuid/uuid.dart'; import '../data/notes_api_client.dart'; import '../data/notes_attachment_references.dart'; import '../data/notes_store.dart'; +import '../data/notes_capabilities.dart'; import '../domain/notes_models.dart'; +import '../domain/notes_retrieval.dart'; import '../domain/notes_conflict.dart'; +import 'notes_navigation.dart'; /// Owns local durability and ordinary optimistic synchronization separately. class NotesRepository { NotesRepository({ required this.store, required Future Function(NextcloudAccount) clientForAccount, - }) : _clientForAccount = clientForAccount; + DateTime Function()? clock, + this.fetchCapabilities, + }) : clock = clock ?? DateTime.now, + _clientForAccount = clientForAccount; final NotesStore store; + final DateTime Function() clock; + final Future Function(NextcloudAccount)? fetchCapabilities; + final _capabilityRequests = >{}; + final _accountGenerations = {}; + final _verifiedInProcess = {}; + final _settingsTransitions = {}; + final _activeMediaNotes = {}; + final _mediaFreshnessEpochs = {}; + final _validatedMediaEpochs = {}; + final _downloads = {}; + static const mediaFreshness = Duration(minutes: 5); final Future Function(NextcloudAccount) _clientForAccount; final _changes = StreamController.broadcast(); final _accounts = {}; final _notes = {}; final _listedIds = >{}; final _baseGenerations = {}; + // Downloads invalidate editor bases; acknowledgments only invalidate older + // reads. Our own acknowledged writes must not create editor conflicts. + final _acknowledgmentGenerations = {}; final _mediaResolutions = >{}; final _mediaVersions = {}; int mediaVersion(String localId) => _mediaVersions[localId] ?? 0; @@ -79,9 +99,919 @@ class NotesRepository { Stream get changes => _changes.stream; NextcloudAccount? accountById(String id) => _accounts[id]; NextcloudNote? noteById(String id) => _notes[id]; + + Future confirmNoteWebRoute(String accountId) async { + _assertMutableAccount(accountId); + final account = _accounts[accountId]!; + if (account.notesWebPrettyUrls case final cached?) return cached; + final generation = accountGeneration(accountId); + final client = await _clientForAccount(account); + _assertMutableAccount(accountId); + if (!_currentAccount(accountId, generation)) { + throw const NotesException( + NotesFailureCode.missing, + 'The Nextcloud account changed.', + ); + } + final pretty = await client.noteWebPrettyUrls(); + return _mutate(() async { + if (!_currentAccount(accountId, generation)) { + throw const NotesException( + NotesFailureCode.missing, + 'The Nextcloud account changed.', + ); + } + final updated = _accounts[accountId]!.copyWith( + notesWebPrettyUrls: pretty, + ); + await store.saveAccount(updated); + _accounts[accountId] = updated; + _notify(); + return pretty; + }); + } + NotesEditorBase editorBase(String id) => NotesEditorBase(_require(id).base, _baseGenerations[id] ?? 0); NotesException? accountError(String accountId) => _accountErrors[accountId]; + bool isSynchronizing(String accountId) => _syncs.containsKey(accountId); + bool _currentAccount(String id, int generation) => + !_disposed && + !_removing.contains(id) && + _accounts.containsKey(id) && + (_accountGenerations[id] ?? 0) == generation; + void _assertMutableAccount(String id) { + if (_disposed || _removing.contains(id) || !_accounts.containsKey(id)) { + throw const NotesException( + NotesFailureCode.missing, + 'The Nextcloud account is no longer available.', + scope: NotesRequestScope.account, + ); + } + if (_settingsTransitions.contains(id) || + _accounts[id]!.settingsAttempt != null) { + throw const NotesException( + NotesFailureCode.conflict, + 'Resolve the Notes settings transition before editing this collection.', + scope: NotesRequestScope.settings, + ); + } + } + + bool _writeEligible(NextcloudNote note) => + note.retryCount <= 6 && + (note.retryNotBefore == null || !clock().isBefore(note.retryNotBefore!)); + bool hasRetryableWork(String id) => notes.any( + (n) => + n.accountId == id && + n.hasPendingChanges && + !_isBlocked(n) && + (n.retryCount > 0 || n.retryNotBefore?.isAfter(clock()) == true) && + n.retryCount <= 6, + ); + DateTime? writeDeadline(String id) { + final deadlines = notes + .where( + (n) => + n.accountId == id && + n.hasPendingChanges && + !_isBlocked(n) && + n.retryCount <= 6, + ) + .map((n) => n.retryNotBefore) + .whereType() + .toList(); + final accountDeadline = + _accounts[id]?.throttleNotBefore ?? accountError(id)?.retryNotBefore; + if (accountDeadline != null) deadlines.add(accountDeadline); + deadlines.sort(); + return deadlines.lastOrNull; + } + + Future retryWrites(String id) => _mutate(() async { + final reset = notes + .where( + (n) => + n.accountId == id && + n.failureCode != null && + { + NotesFailureCode.network, + NotesFailureCode.server, + NotesFailureCode.locked, + NotesFailureCode.throttled, + }.contains(n.failureCode) && + !_isBlocked(n), + ) + .map((n) => n.copyWith(retryCount: 0)) + .toList(); + await store.commit(notes: reset); + for (final n in reset) { + _notes[n.localId] = n; + } + }); + + Future recoverNetworkWrites(String id) => _mutate(() async { + final recovered = notes + .where( + (n) => + n.accountId == id && + n.failureCode == NotesFailureCode.network && + !_isBlocked(n), + ) + .map( + (n) => n.copyWith( + retryCount: 0, + syncState: NoteSyncState.pending, + failureCode: null, + failureScope: null, + ), + ) + .toList(); + await store.commit(notes: recovered); + for (final n in recovered) { + _notes[n.localId] = n; + } + }); + + /// A deliberate corrective action; timers and ordinary Refresh cannot do this. + Future retryRejectedNote(String id) => _mutate(() async { + final current = _require(id); + _assertMutableAccount(current.accountId); + if (!{ + NoteSyncState.rejected, + NoteSyncState.storageFull, + }.contains(current.syncState) || + current.creationAttempt != null || + (current.readonly && + (current.base == null || + _readonlyBlocks(current, current.base!)))) { + throw const NotesException( + NotesFailureCode.conflict, + 'Resolve this operation before retrying it.', + ); + } + for (final attachment in await store.attachments(id)) { + if (attachment.state == 'rejected') { + await store.updateAttachment( + NotesAttachment( + id: attachment.id, + noteId: id, + filename: attachment.filename, + reference: attachment.reference, + remotePath: attachment.remotePath, + state: 'pending', + ), + ); + } + } + final retry = current.copyWith( + syncState: NoteSyncState.pending, + failureCode: null, + failureScope: null, + retryCount: 0, + errorMessage: null, + ); + await store.saveNote(retry); + _notes[id] = retry; + _notify(); + }); + + NotesMetadataSnapshot metadataSnapshot(String id) => + NotesMetadataSnapshot(_require(id), editorBase(id)); + + /// Three-way field merge under the same boundary that owns editor content. + Future patchMetadata( + NotesMetadataSnapshot snapshot, { + String? title, + String? category, + bool? favorite, + }) => _mutate(() async { + final original = snapshot.note; + final current = _require(original.localId); + _assertMutableAccount(original.accountId); + if (current.accountId != original.accountId || + current.serverId != original.serverId) { + throw const NotesException( + NotesFailureCode.conflict, + 'The note identity changed; reopen its properties.', + ); + } + if (isNotesRecovery(current)) { + throw const NotesException( + NotesFailureCode.missing, + 'Recover this deleted note as a new note before editing.', + ); + } + title = title == original.title ? null : title; + category = category == original.category ? null : category; + favorite = favorite == original.favorite ? null : favorite; + if (title == null && category == null && favorite == null) return current; + if ((current.readonly || current.error) && + (title != null || category != null)) { + throw const NotesException( + NotesFailureCode.forbidden, + 'This Nextcloud note became read-only.', + ); + } + final titleDivergent = + (title != null && + current.title != original.title && + current.title != title); + final categoryDivergent = + (category != null && + current.category != original.category && + current.category != category); + final favoriteDivergent = + (favorite != null && + current.favorite != original.favorite && + current.favorite != favorite); + final divergent = titleDivergent || categoryDivergent || favoriteDivergent; + final changed = + (title != null && title != current.title) || + (category != null && category != current.category) || + (favorite != null && favorite != current.favorite); + if (!changed) return current; + final intended = NotesMetadataValues.fromNote( + current, + ).copyWith(title: title, category: category, favorite: favorite); + final previousConflict = current.metadataConflict; + final previousMerge = previousConflict == null + ? null + : NotesConflictMerge.metadata(current); + final metadataConflict = previousConflict == null + ? divergent + ? NotesMetadataConflict( + original: NotesMetadataValues.fromNote(original), + alternative: intended.copyWith( + title: titleDivergent ? current.title : null, + category: categoryDivergent ? current.category : null, + favorite: favoriteDivergent ? current.favorite : null, + ), + ) + : null + : NotesMetadataConflict( + original: previousConflict.original, + alternative: previousConflict.alternative.copyWith( + title: previousMerge!.title.conflicted ? null : intended.title, + category: previousMerge.category.conflicted + ? null + : intended.category, + favorite: previousMerge.favorite.conflicted + ? null + : intended.favorite, + ), + ); + final patched = current.copyWith( + title: title, + category: category, + favorite: favorite, + revision: current.revision + 1, + localActivityMicros: clock().microsecondsSinceEpoch, + metadataConflict: metadataConflict, + syncState: + { + NoteSyncState.creationUncertain, + NoteSyncState.deletedRemotely, + NoteSyncState.recoveryRequired, + NoteSyncState.reconnectRequired, + NoteSyncState.forbidden, + NoteSyncState.unavailable, + NoteSyncState.storageFull, + }.contains(current.syncState) + ? current.syncState + : metadataConflict != null || + current.syncState == NoteSyncState.conflict + ? NoteSyncState.conflict + : _isBlocked(current) && current.syncState != NoteSyncState.rejected + ? current.syncState + : NoteSyncState.pending, + failureCode: null, + failureScope: null, + retryCount: 0, + errorMessage: divergent + ? 'A property changed while the dialog was open. Review both values before synchronizing.' + : current.syncState == NoteSyncState.conflict + ? current.errorMessage + : null, + ); + await store.saveNote(patched); + _notes[current.localId] = patched; + _notify(); + return patched; + }); + + Future> patchMetadataBatch( + List reviewed, { + String? category, + bool? favorite, + }) async { + final outcomes = []; + final seen = {}; + for (final snapshot in reviewed) { + final original = snapshot.note; + if (!seen.add(original.localId)) continue; + try { + final before = noteById(original.localId)?.revision; + final updated = await patchMetadata( + snapshot, + category: category, + favorite: favorite, + ); + outcomes.add( + NotesBatchOutcome( + original.localId, + original.title, + updated.metadataConflict != null || + updated.syncState == NoteSyncState.conflict + ? NotesBatchStatus.conflicted + : updated.revision == before + ? NotesBatchStatus.skipped + : NotesBatchStatus.changed, + ), + ); + } on NotesException catch (error) { + outcomes.add( + NotesBatchOutcome( + original.localId, + original.title, + error.code == NotesFailureCode.forbidden || + error.code == NotesFailureCode.missing + ? NotesBatchStatus.skipped + : error.code == NotesFailureCode.conflict + ? NotesBatchStatus.conflicted + : NotesBatchStatus.failed, + error.message, + ), + ); + } on Object { + outcomes.add( + NotesBatchOutcome( + original.localId, + original.title, + NotesBatchStatus.failed, + 'Local transaction failed; successful items remain saved.', + ), + ); + } + } + return outcomes; + } + + Future> captureLocalSnapshot( + String accountId, + Set ids, + ) => _mutate(() async { + _assertMutableAccount(accountId); + final captured = []; + for (final id in ids) { + final note = _require(id); + if (note.accountId != accountId || isNotesRecovery(note)) { + throw const NotesException( + NotesFailureCode.conflict, + 'The export scope changed; review again.', + ); + } + captured.add(note); + } + return NotesLocalSnapshot( + captured, + mediaVersions: { + for (final note in captured) note.localId: mediaVersion(note.localId), + }, + accountGenerations: {accountId: accountGeneration(accountId)}, + ); + }); + + Future setOfflineRequirement( + String accountId, + String kind, + String target, { + bool required = true, + bool paused = false, + }) => _mutate(() async { + _assertMutableAccount(accountId); + if ((kind == 'note' || kind == 'pause') && + _require(target).accountId != accountId) { + throw const NotesException( + NotesFailureCode.unsafeReference, + 'Wrong offline note account.', + ); + } + await store.setOfflineRequirement( + accountId, + kind, + target, + required: required, + paused: paused, + ); + _notify(); + }); + void cancelMediaDownloads(String localId) { + final note = _notes[localId]; + if (note == null) return; + for (final entry in _downloads.entries.where( + (e) => e.key.startsWith('${note.accountId}:$localId:'), + )) { + entry.value.cancel(); + } + } + + Future< + ({List required, List available, List external}) + > + attachmentAvailability(String localId, {String? source}) async { + final note = _require(localId); + final known = await store.attachments(localId); + final required = {}, available = {}, external = {}; + for (final occurrence in await scanNotesAttachmentReferences( + source ?? note.content, + )) { + final reference = occurrence.reference; + final canonical = canonicalAttachmentReference(reference); + final candidates = known + .where( + (a) => + a.reference == reference || + canonical != null && a.remotePath == canonical, + ) + .toList(); + final attachment = _liveMediaAttachment(candidates); + final pending = reference.startsWith('busymark-attachment:'); + final own = + canonical?.startsWith('.attachments.${note.serverId}/') == true; + final foreign = canonical?.startsWith('.attachments.') == true && !own; + if (candidates.isNotEmpty || + pending || + own || + occurrence.image && canonical != null && !foreign) { + required.add(reference); + if (attachment != null && + await store.attachmentSize(attachment.id) != null) { + available.add(reference); + } + } else if (occurrence.image || foreign) { + external.add(reference); + } + } + return ( + required: required.toList(), + available: available.toList(), + external: external.toList(), + ); + } + + /// An import item's association commits with its new identity and bytes. + /// Synchronization keeps the existing never-sent/uncertain publication logic. + Future importLocal({ + required String accountId, + required String sourceKey, + required String title, + required String category, + required String content, + bool favorite = false, + int? activityMicros, + int? modified, + required Map media, + }) => _mutate(() async { + _assertMutableAccount(accountId); + final existing = await store.importedNote(accountId, sourceKey); + if (existing != null) return _require(existing); + final localId = _uuid.v4(); + final attachments = <({NotesAttachment attachment, Uint8List bytes})>[]; + final replacements = {}; + for (final entry in media.entries) { + final id = _uuid.v4(); + final attachment = NotesAttachment( + id: id, + noteId: localId, + filename: entry.value.filename, + reference: 'busymark-attachment:$accountId:$localId:$id', + ); + attachments.add((attachment: attachment, bytes: entry.value.bytes)); + replacements[entry.key] = attachment.reference; + } + final rewritten = replaceNotesAttachmentReferences( + content, + await scanNotesAttachmentReferences(content), + replacements, + ); + final note = NextcloudNote( + localId: localId, + accountId: accountId, + title: title, + category: category, + content: rewritten, + favorite: favorite, + creationNeverSent: true, + localActivityMicros: activityMicros ?? clock().microsecondsSinceEpoch, + modified: modified ?? 0, + ); + await store.createWithAttachments(note, attachments, importKey: sourceKey); + _notes[localId] = note; + _notify(); + return note; + }); + + final _capabilityEpochs = {}; + int capabilityEpoch(String id) => _capabilityEpochs[id] ?? 0; + int accountGeneration(String id) => _accountGenerations[id] ?? 0; + Future recordApiVersions( + String id, + int generation, + int epoch, + String raw, + ) => _mutate(() async { + if (!_currentAccount(id, generation) || capabilityEpoch(id) != epoch) { + return; + } + final values = raw.split(',').map((v) => v.trim()).toList(); + if (values.isEmpty || + values.any((v) => !RegExp(r'^\d+\.\d+$').hasMatch(v))) { + return; + } + final version = parseNotesApiVersions(values); + final current = _accounts[id]!; + final updated = current.copyWith( + apiVersion: version ?? current.apiVersion, + apiSupported: version != null, + ); + await store.saveAccount(updated); + _accounts[id] = updated; + _capabilityEpochs[id] = epoch + 1; + if (version == null) { + _accountErrors[id] = const NotesException( + NotesFailureCode.unsupported, + 'This server no longer advertises Notes API major 1, minor 4 or later. Local work is preserved.', + scope: NotesRequestScope.capability, + ); + } + _notify(); + }); + + Future maintainCapabilities(String id, {bool force = false}) { + if (_capabilityRequests[id] case final pending?) return pending; + final account = _accounts[id]; + if (fetchCapabilities == null || + account == null || + _disposed || + _removing.contains(id)) { + return Future.value(); + } + if (!force && + _verifiedInProcess.contains(id) && + account.capabilitiesCheckedAt != null && + clock().difference(account.capabilitiesCheckedAt!) < + const Duration(hours: 1)) { + return Future.value(); + } + final generation = accountGeneration(id); + final epoch = capabilityEpoch(id) + 1; + _capabilityEpochs[id] = epoch; + final future = () async { + try { + final capabilities = await fetchCapabilities!(account); + await _mutate(() async { + if (!_currentAccount(id, generation)) return; + final current = _accounts[id]!; + final updated = current.copyWith( + appVersion: capabilities.appVersion, + apiVersion: capabilityEpoch(id) == epoch + ? capabilities.apiVersion + : current.apiVersion, + apiSupported: capabilityEpoch(id) == epoch + ? true + : current.apiSupported, + capabilitiesCheckedAt: clock(), + ); + await store.saveAccount(updated); + _accounts[id] = updated; + _verifiedInProcess.add(id); + _notify(); + }); + } on NextcloudCapabilityException catch (error) { + if (!_currentAccount(id, generation)) return; + if (error.code == NextcloudCapabilityFailure.unsupported) { + await _mutate(() async { + if (!_currentAccount(id, generation) || + capabilityEpoch(id) != epoch) { + return; + } + final current = _accounts[id]!.copyWith( + apiSupported: false, + capabilitiesCheckedAt: clock(), + ); + await store.saveAccount(current); + _accounts[id] = current; + }); + } + throw NotesException( + switch (error.code) { + NextcloudCapabilityFailure.unsupported => + NotesFailureCode.unsupported, + NextcloudCapabilityFailure.unauthorized => + NotesFailureCode.authentication, + NextcloudCapabilityFailure.malformed => + NotesFailureCode.invalidResponse, + NextcloudCapabilityFailure.network => NotesFailureCode.network, + NextcloudCapabilityFailure.rejected => NotesFailureCode.rejected, + NextcloudCapabilityFailure.forbidden => NotesFailureCode.forbidden, + NextcloudCapabilityFailure.throttled => NotesFailureCode.throttled, + }, + error.message, + scope: NotesRequestScope.capability, + statusCode: error.statusCode, + retryNotBefore: error.retryNotBefore, + ); + } + }(); + _capabilityRequests[id] = future; + return future.whenComplete(() { + if (identical(_capabilityRequests[id], future)) { + _capabilityRequests.remove(id); + } + }); + } + + Future getSettings(String id) async { + final generation = accountGeneration(id); + final account = _accounts[id]; + if (account == null) { + throw const NotesException( + NotesFailureCode.missing, + 'The account was removed.', + scope: NotesRequestScope.account, + ); + } + _checkThrottle(account); + late NotesSettings settings; + try { + settings = await (await _clientForAccount(account)).getSettings(); + } on NotesException catch (error) { + await _rememberThrottle(id, error); + rethrow; + } + if (!_currentAccount(id, generation)) { + throw const NotesException( + NotesFailureCode.missing, + 'The account was removed or reconnected.', + scope: NotesRequestScope.account, + ); + } + return settings; + } + + bool hasSettingsBlockers(String id) => notes.any( + (n) => + n.accountId == id && + (n.hasPendingChanges || + n.syncState == NoteSyncState.conflict || + n.syncState == NoteSyncState.creationUncertain), + ); + + Future changeSettings( + String id, + NotesSettings original, + Map patch, + ) async { + if (patch.isEmpty) return original; + _assertMutableAccount(id); + _settingsTransitions.add(id); + final generation = accountGeneration(id); + try { + await _syncs[id]; + final fresh = await getSettings(id); + for (final field in patch.keys) { + if (fresh.toJson()[field] != original.toJson()[field] && + fresh.toJson()[field] != patch[field]) { + throw const NotesException( + NotesFailureCode.conflict, + 'Server settings changed while this form was open. Reload and review them.', + scope: NotesRequestScope.settings, + ); + } + } + final pathChange = + patch.containsKey('notesPath') && + patch['notesPath'] != fresh.notesPath; + final suffixChange = + patch.containsKey('fileSuffix') && + patch['fileSuffix'] != fresh.fileSuffix; + final attempt = NotesSettingsAttempt( + id: _uuid.v4(), + original: fresh, + patch: patch, + ); + await _mutate(() async { + if (!_currentAccount(id, generation)) { + throw const NotesException( + NotesFailureCode.missing, + 'The account was removed.', + scope: NotesRequestScope.account, + ); + } + final attachments = await store.attachments(); + if ((pathChange || suffixChange) && + (hasSettingsBlockers(id) || + attachments.any( + (a) => + _notes[a.noteId]?.accountId == id && + !{'cached', 'uploaded', 'deleted'}.contains(a.state), + ))) { + throw const NotesException( + NotesFailureCode.conflict, + 'Synchronize or resolve pending notes, conflicts and attachment operations before changing the server collection.', + scope: NotesRequestScope.settings, + ); + } + final current = _accounts[id]!.copyWith(settingsAttempt: attempt); + await store.saveAccount(current); + _accounts[id] = current; + }); + NotesSettings result; + try { + result = await (await _clientForAccount( + _accounts[id]!, + )).updateSettings(patch); + } on NotesException catch (error) { + await _rememberThrottle(id, error); + if (!error.possiblyExecuted) { + await _finishSettings( + id, + generation, + attempt, + fresh, + resetCheckpoint: false, + ); + rethrow; + } + // A lost response may follow normalization/partial execution. Read first; + // if that read fails, the durable attempt fences writes across restart. + result = await getSettings(id); + if (patch.keys.every( + (key) => result.toJson()[key] == fresh.toJson()[key], + )) { + await _finishSettings( + id, + generation, + attempt, + result, + resetCheckpoint: + result.notesPath != fresh.notesPath || + result.fileSuffix != fresh.fileSuffix, + ); + throw const NotesException( + NotesFailureCode.rejected, + 'The settings response was lost and the server still reports the previous values. Review them before saving again.', + scope: NotesRequestScope.settings, + ); + } + } + await _finishSettings( + id, + generation, + attempt, + result, + resetCheckpoint: + pathChange || + suffixChange || + result.notesPath != fresh.notesPath || + result.fileSuffix != fresh.fileSuffix, + ); + return result; + } finally { + _settingsTransitions.remove(id); + } + } + + Future _finishSettings( + String id, + int generation, + NotesSettingsAttempt attempt, + NotesSettings result, { + required bool resetCheckpoint, + }) => _mutate(() async { + if (!_currentAccount(id, generation) || + _accounts[id]!.settingsAttempt?.id != attempt.id) { + throw const NotesException( + NotesFailureCode.missing, + 'The account changed during the settings request.', + scope: NotesRequestScope.account, + ); + } + final current = _accounts[id]!.copyWith( + settingsAttempt: null, + listEtag: resetCheckpoint ? null : _accounts[id]!.listEtag, + lastModified: resetCheckpoint ? null : _accounts[id]!.lastModified, + ); + await store.saveAccount(current); + _accounts[id] = current; + _notify(); + }); + + void _checkThrottle(NextcloudAccount account) { + final deadline = account.throttleNotBefore; + if (deadline != null && clock().isBefore(deadline)) { + throw NotesException( + NotesFailureCode.throttled, + 'Nextcloud requested a pause before the next request.', + scope: NotesRequestScope.account, + retryNotBefore: deadline, + ); + } + } + + Future _rememberThrottle(String id, NotesException error) => + _mutate(() async { + final account = _accounts[id]; + if (account == null || + _disposed || + _removing.contains(id) || + error.retryNotBefore == null) { + return; + } + final previous = account.throttleNotBefore; + final deadline = + previous != null && previous.isAfter(error.retryNotBefore!) + ? previous + : error.retryNotBefore; + final updated = account.copyWith(throttleNotBefore: deadline); + await store.saveAccount(updated); + _accounts[id] = updated; + }); + Future reconcileSettings(String id) async { + final attempt = _accounts[id]?.settingsAttempt; + final generation = accountGeneration(id); + final result = await getSettings(id); + if (attempt != null) { + await _finishSettings( + id, + generation, + attempt, + result, + resetCheckpoint: true, + ); + } + return result; + } + + void setDisplayedMediaNotes(Iterable ids) { + _activeMediaNotes + ..clear() + ..addAll(ids); + } + + void _invalidateMediaFreshness(String id) { + _mediaFreshnessEpochs[id] = (_mediaFreshnessEpochs[id] ?? 0) + 1; + for (final entry in _downloads.entries.where( + (e) => e.key.contains(':$id:'), + )) { + entry.value.cancel(); + } + _mediaVersions[id] = mediaVersion(id) + 1; + } + + Future refreshDisplayedMedia({bool force = false}) async { + for (final id in _activeMediaNotes.toList()) { + final note = _notes[id]; + if (note == null || _disposed || _removing.contains(note.accountId)) { + continue; + } + final used = (await scanNotesAttachmentReferences(note.content)) + .map((r) => canonicalAttachmentReference(r.reference)) + .whereType() + .toSet(); + final cached = (await store.attachments(id)) + .where( + (a) => + a.remotePath != null && + used.contains(a.remotePath) && + {'cached', 'uploaded'}.contains(a.state), + ) + .toList(); + if (force) { + _invalidateMediaFreshness(id); + await Future.wait( + _mediaResolutions.entries + .where((e) => e.key.startsWith('${note.accountId}:$id:')) + .map( + (e) => e.value.then( + (_) {}, + onError: (Object _, StackTrace _) {}, + ), + ) + .toList(), + ); + } + for (final attachment in cached) { + await resolveMedia( + note.accountId, + id, + attachmentMarkdownReference(attachment.remotePath!), + ); + } + } + } + Future> attachments(String localId) => store.attachments(localId); @@ -159,12 +1089,71 @@ class NotesRepository { 'This note is no longer available.', )); - Future upsertAccount(NextcloudAccount account) async { + Future upsertAccount( + NextcloudAccount account, { + bool reconnect = false, + }) async { await initialize(); _validateId(account.id); + // Finish requests using the old credential before releasing their durable + // authentication blocks. Network waits stay outside the mutation queue. + if (reconnect) await _syncs[account.id]; await _mutate(() async { - await store.saveAccount(account); - _accounts[account.id] = account; + final existing = _accounts[account.id]; + if (reconnect && + (existing == null || + existing.server != account.server || + existing.loginName != account.loginName)) { + throw const NotesException( + NotesFailureCode.conflict, + 'Reconnect must verify the same Nextcloud account.', + scope: NotesRequestScope.account, + ); + } + final merged = existing == null + ? account + : existing.copyWith( + appVersion: account.appVersion, + apiVersion: account.apiVersion, + apiSupported: account.apiSupported, + capabilitiesCheckedAt: account.capabilitiesCheckedAt, + notesWebPrettyUrls: reconnect + ? null + : existing.notesWebPrettyUrls, + ); + final recovered = reconnect + ? notes + .where( + (note) => + note.accountId == account.id && + note.syncState == NoteSyncState.reconnectRequired && + note.creationAttempt == null, + ) + .map( + (note) => note.copyWith( + syncState: + note.metadataConflict != null || note.remote != null + ? NoteSyncState.conflict + : NoteSyncState.pending, + failureCode: null, + failureScope: null, + errorMessage: null, + retryCount: 0, + ), + ) + .toList() + : []; + await store.commit(accounts: [merged], notes: recovered); + _accounts[account.id] = merged; + for (final note in recovered) { + _notes[note.localId] = note; + } + if (reconnect) _accountErrors.remove(account.id); + if (reconnect || existing == null) { + _accountGenerations[account.id] = + (_accountGenerations[account.id] ?? 0) + 1; + } + _verifiedInProcess.remove(account.id); _notify(); }); } @@ -183,12 +1172,15 @@ class NotesRepository { ); } return _mutate(() async { + _assertMutableAccount(accountId); final note = NextcloudNote( + creationNeverSent: true, localId: _uuid.v4(), accountId: accountId, title: title, category: category, content: content, + localActivityMicros: clock().microsecondsSinceEpoch, ); await store.saveNote(note); _notes[note.localId] = note; @@ -208,6 +1200,7 @@ class NotesRepository { bool? favorite, }) => _mutate(() async { var current = _require(localId); + _assertMutableAccount(current.accountId); final digest = sha256.convert(utf8.encode(content)).toString(); if (editorRevision != null && (editorRevision < current.editorRevision || @@ -257,6 +1250,7 @@ class NotesRepository { NoteSyncState.conflict, NoteSyncState.creationUncertain, NoteSyncState.deletedRemotely, + NoteSyncState.recoveryRequired, }.contains(current.syncState); final revision = editorRevision != null && editorRevision > current.revision ? editorRevision @@ -267,6 +1261,10 @@ class NotesRepository { category: category, favorite: favorite, revision: revision, + localActivityMicros: clock().microsecondsSinceEpoch, + failureCode: null, + failureScope: null, + retryCount: 0, editorRevision: editorRevision ?? current.editorRevision, editorContentDigest: editorRevision == null ? current.editorContentDigest @@ -295,67 +1293,130 @@ class NotesRepository { return saved; }); - Future synchronize(String accountId) { + Future synchronize( + String accountId, { + bool allowWrites = true, + bool refreshCapabilities = false, + bool onlyFreshWrites = false, + }) { if (_disposed || _removing.contains(accountId)) return Future.value(); final existing = _syncs[accountId]; if (existing != null) return existing; - final future = _synchronize(accountId); + final future = _synchronize( + accountId, + allowWrites: allowWrites, + refreshCapabilities: refreshCapabilities, + onlyFreshWrites: onlyFreshWrites, + ); _syncs[accountId] = future; + _notify(); future.then( (_) { _syncs.remove(accountId); + _notify(); }, onError: (Object _, StackTrace _) { _syncs.remove(accountId); + _notify(); }, ); return future; } - Future _synchronize(String accountId) async { + Future _synchronize( + String accountId, { + required bool allowWrites, + required bool refreshCapabilities, + required bool onlyFreshWrites, + }) async { await initialize(); - final account = _accounts[accountId]; - if (account == null) return; + var account = _accounts[accountId]; + if (account == null || _settingsTransitions.contains(accountId)) return; + final generation = _accountGenerations[accountId] ?? 0; + final deadline = + _accounts[accountId]?.throttleNotBefore ?? + _accountErrors[accountId]?.retryNotBefore; + if (deadline != null && clock().isBefore(deadline)) { + _accountErrors[accountId] = NotesException( + NotesFailureCode.throttled, + 'Nextcloud requested a pause before the next server check.', + scope: NotesRequestScope.account, + retryNotBefore: deadline, + ); + _notify(); + return; + } late NotesApiClient client; try { + await maintainCapabilities(accountId, force: refreshCapabilities); + account = _accounts[accountId]; + if (account == null || + !_currentAccount(accountId, generation) || + !account.apiSupported) { + return; + } + if (account.settingsAttempt != null) { + await reconcileSettings(accountId); + account = _accounts[accountId]; + if (account == null || account.settingsAttempt != null) return; + } client = await _clientForAccount(account); final list = await client.list( forceFull: _notes.values.any( (n) => n.accountId == accountId && - n.syncState == NoteSyncState.creationUncertain, + (n.syncState == NoteSyncState.creationUncertain || + (n.syncState == NoteSyncState.deletedRemotely && + n.deletionEvidence == null)), ), ); + if (!_currentAccount(accountId, generation)) return; if (!list.notModified) { await _applyList(account, list); _listedIds[accountId] = list.ids; } - if (_accountErrors.remove(accountId) != null) _notify(); + await _mutate(() async { + if (!_currentAccount(accountId, generation)) return; + final checked = _accounts[accountId]!.copyWith( + lastServerCheck: clock(), + ); + await store.saveAccount(checked); + _accounts[accountId] = checked; + if (checked.apiSupported) _accountErrors.remove(accountId); + _notify(); + }); } on NotesException catch (error) { await _markAccountError(accountId, error); return; - } catch (_) { - await _markAccountError( - accountId, - const NotesException( - NotesFailureCode.authentication, - 'Unlock your keyring or reconnect to Nextcloud.', - ), - ); + } + if (!allowWrites || + !_currentAccount(accountId, generation) || + _accounts[accountId]?.apiSupported != true) { return; } // One pass, bounded retries through deliberate refresh, never a lock spin loop. final pendingIds = _notes.values .where( (n) => - n.accountId == accountId && n.hasPendingChanges && !_isBlocked(n), + n.accountId == accountId && + n.hasPendingChanges && + (!onlyFreshWrites || + (n.retryCount == 0 && n.failureCode == null)) && + !_isBlocked(n) && + _writeEligible(n), ) .map((n) => n.localId) .toList(); for (final id in pendingIds) { - if (_removing.contains(accountId)) return; + if (!_currentAccount(accountId, generation)) return; var note = _notes[id]; - if (note == null || _isBlocked(note)) continue; + if (note == null || + _isBlocked(note) || + !_writeEligible(note) || + (onlyFreshWrites && + (note.retryCount != 0 || note.failureCode != null))) { + continue; + } try { if (note.serverId == null) { final sent = await _markCreationSending(id); @@ -364,10 +1425,7 @@ class NotesRepository { await _acknowledge(sent, remote); } on NotesException catch (error) { // 5xx/malformed/transport failure can happen after a successful POST. - if (error.code == NotesFailureCode.network || - error.code == NotesFailureCode.server || - error.code == NotesFailureCode.invalidResponse || - error.code == NotesFailureCode.locked) { + if (error.possiblyExecuted) { await _setState( id, NoteSyncState.creationUncertain, @@ -380,7 +1438,10 @@ class NotesRepository { if (current.creationAttempt?.id != sent.creationAttempt?.id) { return; } - final rejected = current.copyWith(creationAttempt: null); + final rejected = current.copyWith( + creationAttempt: null, + creationNeverSent: true, + ); await store.saveNote(rejected); _notes[id] = rejected; }); @@ -408,6 +1469,7 @@ class NotesRepository { } bool _isBlocked(NextcloudNote note) => + note.metadataConflict != null || (note.serverId == null && note.creationAttempt != null) || note.error || (note.readonly && @@ -420,6 +1482,11 @@ class NotesRepository { NoteSyncState.creationUncertain, NoteSyncState.deletedRemotely, NoteSyncState.unavailable, + NoteSyncState.rejected, + NoteSyncState.forbidden, + NoteSyncState.reconnectRequired, + NoteSyncState.storageFull, + NoteSyncState.recoveryRequired, }.contains(note.syncState); bool _readonlyBlocks(NextcloudNote note, NoteState remote) => @@ -472,63 +1539,12 @@ class NotesRepository { ); } for (final remote in list.notes) { - final current = existing[remote.id]; - if (current == null) { - updated.add(_fromRemote(_uuid.v4(), account.id, remote)); - } else if (remote.error) { - updated.add( - current.copyWith( - readonly: true, - error: true, - remote: remote, - syncState: NoteSyncState.unavailable, - errorMessage: - 'Nextcloud could not read this note. Cached content has been preserved.', - ), - ); - } else if (current.hasPendingChanges) { - if (remote.etag != current.etag) { - updated.add( - current.copyWith( - remote: remote, - readonly: remote.readonly, - syncState: NoteSyncState.conflict, - errorMessage: - 'The note changed on Nextcloud while local changes were pending.', - ), - ); - } else if (current.readonly != remote.readonly || current.error) { - final forbidden = - _readonlyBlocks(current, remote) && - current.syncState != NoteSyncState.conflict; - updated.add( - current.copyWith( - readonly: remote.readonly, - error: false, - syncState: forbidden - ? NoteSyncState.forbidden - : current.syncState == NoteSyncState.unavailable - ? NoteSyncState.pending - : current.syncState, - errorMessage: forbidden - ? 'Nextcloud became read-only. Local changes are preserved; recover them as a new note.' - : current.syncState == NoteSyncState.unavailable - ? null - : current.errorMessage, - ), - ); - } - } else { - updated.add( - _fromRemote( - current.localId, - account.id, - remote, - revision: current.revision, - previous: current, - ), - ); - } + final reconciled = _reconcileServerNote( + account.id, + existing[remote.id], + remote, + ); + if (reconciled != null) updated.add(reconciled); } // Only list()'s fully received final chunk may supply the complete ID set. for (final note in existing.values) { @@ -536,6 +1552,7 @@ class NotesRepository { updated.add( note.copyWith( syncState: NoteSyncState.deletedRemotely, + deletionEvidence: 'completeList', errorMessage: note.hasPendingChanges ? 'This note was deleted on Nextcloud. Recover as a new note or discard local changes.' : null, @@ -543,6 +1560,26 @@ class NotesRepository { ); } } + for (final draft in _notes.values.where( + (n) => + n.accountId == account.id && + n.serverId == null && + n.syncState == NoteSyncState.deletedRemotely && + n.deletionEvidence == null && + n.ackRevision == 0 && + n.creationAttempt == null, + )) { + updated.add( + draft.copyWith( + syncState: draft.creationNeverSent + ? NoteSyncState.pending + : NoteSyncState.recoveryRequired, + errorMessage: draft.creationNeverSent + ? null + : 'This older record has no reliable creation history. Review the server notes before deliberately creating a separate note; its content and attachment bytes are retained.', + ), + ); + } // Reconnection may refresh app/capability metadata while this request is // in flight. Commit the checkpoint onto the current non-secret account. final checkpoint = (_accounts[account.id] ?? account).withCheckpoint( @@ -553,6 +1590,7 @@ class NotesRepository { _accounts[account.id] = checkpoint; for (final note in updated) { if (note.etag != _notes[note.localId]?.etag) { + _invalidateMediaFreshness(note.localId); _baseGenerations[note.localId] = (_baseGenerations[note.localId] ?? 0) + 1; } @@ -561,6 +1599,199 @@ class NotesRepository { _notify(); }); + NextcloudNote? _reconcileServerNote( + String accountId, + NextcloudNote? current, + NoteState remote, + ) { + final updated = []; + if (current == null) { + updated.add(_fromRemote(_uuid.v4(), accountId, remote)); + } else if (remote.error) { + updated.add( + current.copyWith( + readonly: true, + error: true, + remote: remote, + syncState: NoteSyncState.unavailable, + errorMessage: + 'Nextcloud could not read this note. Cached content has been preserved.', + ), + ); + } else if (current.hasPendingChanges) { + if (current.syncState == NoteSyncState.deletedRemotely && + current.deletionEvidence == null && + remote.etag == current.etag) { + updated.add( + current.copyWith( + syncState: NoteSyncState.pending, + errorMessage: null, + failureCode: null, + failureScope: null, + ), + ); + } else if (remote.etag != current.etag) { + updated.add( + current.copyWith( + remote: remote, + readonly: remote.readonly, + syncState: NoteSyncState.conflict, + errorMessage: + 'The note changed on Nextcloud while local changes were pending.', + ), + ); + } else if (current.readonly != remote.readonly || current.error) { + final forbidden = + _readonlyBlocks(current, remote) && + current.syncState != NoteSyncState.conflict; + updated.add( + current.copyWith( + readonly: remote.readonly, + error: false, + syncState: forbidden + ? NoteSyncState.forbidden + : current.syncState == NoteSyncState.unavailable + ? NoteSyncState.pending + : current.syncState, + errorMessage: forbidden + ? 'Nextcloud became read-only. Local changes are preserved; recover them as a new note.' + : current.syncState == NoteSyncState.unavailable + ? null + : current.errorMessage, + ), + ); + } + } else { + updated.add( + _fromRemote( + current.localId, + accountId, + remote, + revision: current.revision, + previous: current, + ), + ); + } + return updated.singleOrNull; + } + + /// Individually opening a link uses the same reconciliation as full sync, + /// without collection checkpoints or deletion inference. + Future linkedNote(String accountId, int serverId) async { + await initialize(); + _assertMutableAccount(accountId); + final cached = _notes.values + .where((n) => n.accountId == accountId && n.serverId == serverId) + .firstOrNull; + if (cached != null) { + if (isNotesRecovery(cached)) { + throw const NotesException( + NotesFailureCode.missing, + 'This linked note was deleted.', + ); + } + return cached; + } + return refreshLinkedNote(accountId, serverId); + } + + Future queryNotes( + String accountId, + NotesRetrievalScope scope, { + int chunkSize = 100, + }) async { + await initialize(); + _assertMutableAccount(accountId); + final generation = _accountGenerations[accountId] ?? 0; + final client = await _clientForAccount(_accounts[accountId]!); + if (!_currentAccount(accountId, generation)) { + throw const NotesException( + NotesFailureCode.missing, + 'The queried account was removed.', + scope: NotesRequestScope.account, + ); + } + final result = await client.query(scope, chunkSize: chunkSize); + if (!_currentAccount(accountId, generation)) { + throw const NotesException( + NotesFailureCode.missing, + 'The queried account was removed.', + scope: NotesRequestScope.account, + ); + } + return result; + } + + Future refreshLinkedNote( + String accountId, + int serverId, + ) async { + await initialize(); + _assertMutableAccount(accountId); + final account = _accounts[accountId]!; + final generation = _accountGenerations[accountId] ?? 0; + final cached = _notes.values + .where((n) => n.accountId == accountId && n.serverId == serverId) + .firstOrNull; + final baseGeneration = cached == null + ? null + : _baseGenerations[cached.localId] ?? 0; + final acknowledgmentGeneration = cached == null + ? null + : _acknowledgmentGenerations[cached.localId] ?? 0; + final client = await _clientForAccount(account); + if (!_currentAccount(accountId, generation)) { + throw const NotesException( + NotesFailureCode.missing, + 'The linked account was removed.', + scope: NotesRequestScope.account, + ); + } + final result = await client.retrieve(serverId, cached: cached?.base); + return _mutate(() async { + if (!_currentAccount(accountId, generation)) { + throw const NotesException( + NotesFailureCode.missing, + 'The linked account was removed.', + scope: NotesRequestScope.account, + ); + } + final current = _notes.values + .where((n) => n.accountId == accountId && n.serverId == serverId) + .firstOrNull; + if (current != null && + (isNotesRecovery(current) || + cached == null || + (_baseGenerations[current.localId] ?? 0) != baseGeneration || + (_acknowledgmentGenerations[current.localId] ?? 0) != + acknowledgmentGeneration)) { + return current; + } + if (result is NotesNotModified) { + if (current == null) { + throw const NotesException( + NotesFailureCode.invalidResponse, + 'The cached linked note is no longer available.', + ); + } + // A 304 is a read observation; never an acknowledgment of local writes. + return current; + } + final remote = (result as NotesReturned).note; + final reconciled = _reconcileServerNote(accountId, current, remote); + if (reconciled == null) return current!; + await store.commit(notes: [reconciled]); + _notes[reconciled.localId] = reconciled; + if (reconciled.etag != current?.etag) { + _invalidateMediaFreshness(reconciled.localId); + _baseGenerations[reconciled.localId] = + (_baseGenerations[reconciled.localId] ?? 0) + 1; + } + _notify(); + return reconciled; + }); + } + NextcloudNote _fromRemote( String localId, String accountId, @@ -582,6 +1813,7 @@ class NotesRepository { base: remote, revision: revision, ackRevision: revision, + localActivityMicros: previous?.localActivityMicros, editorRevision: previous?.editorRevision ?? 0, editorContentDigest: previous?.editorContentDigest, syncState: remote.error ? NoteSyncState.unavailable : NoteSyncState.synced, @@ -612,6 +1844,7 @@ class NotesRepository { ); final sent = current.copyWith( creationAttempt: attempt, + creationNeverSent: false, syncState: NoteSyncState.syncing, errorMessage: null, ); @@ -633,11 +1866,17 @@ class NotesRepository { base: remote, remote: null, content: current.content, - title: current.title == wire['title'] ? remote.title : current.title, - category: current.category == wire['category'] + title: current.metadataConflict == null && current.title == wire['title'] + ? remote.title + : current.title, + category: + current.metadataConflict == null && + current.category == wire['category'] ? remote.category : current.category, - favorite: current.favorite == wire['favorite'] + favorite: + current.metadataConflict == null && + current.favorite == wire['favorite'] ? remote.favorite : current.favorite, readonly: remote.readonly, @@ -645,11 +1884,15 @@ class NotesRepository { error: false, ackRevision: attempt.revision, revision: latest && staged ? current.revision + 1 : current.revision, - syncState: latest && !staged + syncState: current.metadataConflict != null + ? NoteSyncState.conflict + : latest && !staged ? NoteSyncState.synced : NoteSyncState.pending, creationAttempt: null, - errorMessage: null, + errorMessage: current.metadataConflict != null + ? current.errorMessage + : null, ); return _readonlyBlocks(adopted, remote) ? adopted.copyWith( @@ -661,9 +1904,19 @@ class NotesRepository { } Future _markSending(String id) => _mutate(() async { - final sent = _require( - id, - ).copyWith(syncState: NoteSyncState.syncing, errorMessage: null); + final current = _require(id); + // Revalidate inside the mutation boundary: a properties review can become + // conflicted while attachment publication or an earlier queue entry awaits. + if (_isBlocked(current)) { + throw const NotesException( + NotesFailureCode.conflict, + 'Resolve the outstanding note conflict before publishing another revision.', + ); + } + final sent = current.copyWith( + syncState: NoteSyncState.syncing, + errorMessage: null, + ); await store.saveNote(sent); _notes[id] = sent; _notify(); @@ -714,22 +1967,36 @@ class NotesRepository { base: remote, remote: null, ackRevision: sent.revision, + failureCode: null, + failureScope: null, + retryCount: 0, + retryNotBefore: null, content: latest && !staged ? remote.content : current.content, - title: latest || current.title == sent.title + title: + current.metadataConflict == null && + (latest || current.title == sent.title) ? remote.title : current.title, - category: latest || current.category == sent.category + category: + current.metadataConflict == null && + (latest || current.category == sent.category) ? remote.category : current.category, - favorite: latest ? remote.favorite : current.favorite, + favorite: latest && current.metadataConflict == null + ? remote.favorite + : current.favorite, readonly: remote.readonly, error: false, modified: remote.modified, revision: latest && staged ? current.revision + 1 : current.revision, - syncState: latest && !staged + syncState: current.metadataConflict != null + ? NoteSyncState.conflict + : latest && !staged ? NoteSyncState.synced : NoteSyncState.pending, - errorMessage: null, + errorMessage: current.metadataConflict != null + ? current.errorMessage + : null, ); if (acknowledged.hasPendingChanges && _readonlyBlocks(acknowledged, remote)) { @@ -741,6 +2008,8 @@ class NotesRepository { } await store.saveNote(acknowledged); _notes[sent.localId] = acknowledged; + _acknowledgmentGenerations[sent.localId] = + (_acknowledgmentGenerations[sent.localId] ?? 0) + 1; } _notify(); }); @@ -769,12 +2038,14 @@ class NotesRepository { NotesApiClient? client, }) async { var remote = error.remote; + final serverId = _notes[id]?.serverId; if ((error.code == NotesFailureCode.conflict || error.code == NotesFailureCode.forbidden) && remote == null && - client != null) { + client != null && + serverId != null) { try { - remote = await client.get(_require(id).serverId!); + remote = await client.get(serverId); } on NotesException { /* Preserve base/local. */ } @@ -782,24 +2053,71 @@ class NotesRepository { final state = switch (error.code) { NotesFailureCode.authentication => NoteSyncState.reconnectRequired, NotesFailureCode.forbidden => NoteSyncState.forbidden, - NotesFailureCode.missing => NoteSyncState.deletedRemotely, + NotesFailureCode.missing => + error.scope == NotesRequestScope.note + ? NoteSyncState.deletedRemotely + : NoteSyncState.rejected, + NotesFailureCode.rejected || + NotesFailureCode.invalidResponse || + NotesFailureCode.unsafeReference || + NotesFailureCode.unsupported => NoteSyncState.rejected, + NotesFailureCode.throttled => NoteSyncState.throttled, NotesFailureCode.conflict => NoteSyncState.conflict, NotesFailureCode.locked => NoteSyncState.locked, NotesFailureCode.storageFull => NoteSyncState.storageFull, NotesFailureCode.network => NoteSyncState.offline, _ => NoteSyncState.pending, }; - await _setState(id, state, error.message, remote: remote); + await _mutate(() async { + final current = _notes[id]; + if (current == null || + _disposed || + _removing.contains(current.accountId)) { + return; + } + final updated = current.copyWith( + // A retryable failure belongs to the earlier request, not to the + // disposition of newer metadata edits. Keep its retry evidence without + // replacing the review that independently blocks publication. + syncState: current.metadataConflict != null && error.retryable + ? NoteSyncState.conflict + : state, + errorMessage: current.metadataConflict != null && error.retryable + ? current.errorMessage + : error.message, + remote: remote ?? current.remote, + readonly: remote?.readonly ?? current.readonly, + failureCode: error.code, + failureScope: error.scope, + retryNotBefore: error.retryNotBefore, + retryCount: error.retryable + ? current.retryCount + 1 + : current.retryCount, + deletionEvidence: state == NoteSyncState.deletedRemotely + ? 'individualNote' + : current.deletionEvidence, + ); + await store.saveNote(updated); + _notes[id] = updated; + _notify(); + }); } Future _markAccountError(String accountId, NotesException error) async { - _accountErrors[accountId] = error; - _notify(); - for (final note in notes.where( - (n) => n.accountId == accountId && n.hasPendingChanges && !_isBlocked(n), - )) { - await _recordError(note.localId, error); + if (_disposed || + _removing.contains(accountId) || + !_accounts.containsKey(accountId)) { + return; } + _accountErrors[accountId] = _accounts[accountId]!.apiSupported + ? error + : const NotesException( + NotesFailureCode.unsupported, + 'This server no longer advertises Notes API major 1, minor 4 or later. Local work is preserved.', + scope: NotesRequestScope.capability, + ); + await _rememberThrottle(accountId, error); + _notify(); } /// Recreates a remote recovery/history snapshot with its own durable staged @@ -903,6 +2221,8 @@ class NotesRepository { previousStart = occurrence.start; } final recovered = NextcloudNote( + creationNeverSent: true, + localActivityMicros: clock().microsecondsSinceEpoch, localId: localId, accountId: original.accountId, title: title ?? original.title, @@ -1123,7 +2443,9 @@ class NotesRepository { try { await recoverAsNew( localId, - creationDecision: original.serverId == null, + creationDecision: + original.serverId == null && + original.syncState == NoteSyncState.creationUncertain, ); } finally { _creationResolutions.remove(localId); @@ -1141,6 +2463,15 @@ class NotesRepository { await _resolveCreation(original, creationReview); return; } + if (original.metadataConflict != null) { + await _resolveMetadataConflict( + original, + resolution, + mergedContent: mergedContent, + metadataChoices: metadataChoices, + ); + return; + } if (original.serverId == null) { throw const NotesException( NotesFailureCode.conflict, @@ -1174,9 +2505,8 @@ class NotesRepository { 'New local edits appeared while resolving this note. Review them before applying the resolution.', ); } - if (resolution != NoteConflictResolution.takeRemote && - original.remote != null && - fresh.etag != original.remote!.etag) { + if (original.remote != null && + jsonEncode(fresh.toJson()) != jsonEncode(original.remote!.toJson())) { final changedAgain = current.copyWith( remote: fresh, readonly: fresh.readonly, @@ -1233,6 +2563,10 @@ class NotesRepository { readonly: fresh.readonly, error: false, revision: current.revision + 1, + localActivityMicros: clock().microsecondsSinceEpoch, + failureCode: null, + failureScope: null, + retryCount: 0, syncState: NoteSyncState.pending, errorMessage: null, ); @@ -1250,6 +2584,107 @@ class NotesRepository { }); } + Future _resolveMetadataConflict( + NextcloudNote original, + NoteConflictResolution resolution, { + String? mergedContent, + required Map metadataChoices, + }) async { + _assertMutableAccount(original.accountId); + final generation = accountGeneration(original.accountId); + NoteState? fresh; + if (original.serverId != null) { + final client = await _clientForAccount(_accounts[original.accountId]!); + fresh = await client.get(original.serverId!); + if (fresh.error) { + throw const NotesException( + NotesFailureCode.forbidden, + 'The server note is unavailable.', + ); + } + } + await _mutate(() async { + if (!_currentAccount(original.accountId, generation)) { + throw const NotesException( + NotesFailureCode.conflict, + 'The account changed while reviewing these properties.', + ); + } + _assertMutableAccount(original.accountId); + final current = _require(original.localId); + if (current.revision != original.revision || + current.metadataConflict == null) { + throw const NotesException( + NotesFailureCode.conflict, + 'New local edits appeared. Review the conflict again.', + ); + } + final merge = NotesConflictMerge.metadata(current); + final alternative = current.metadataConflict!.alternative; + final merging = resolution == NoteConflictResolution.merge; + final previous = resolution == NoteConflictResolution.takeRemote; + final serverChanged = fresh != null && fresh.etag != current.etag; + final protectedState = { + NoteSyncState.creationUncertain, + NoteSyncState.deletedRemotely, + NoteSyncState.recoveryRequired, + NoteSyncState.reconnectRequired, + NoteSyncState.forbidden, + NoteSyncState.unavailable, + NoteSyncState.storageFull, + }.contains(current.syncState); + final resolved = current.copyWith( + content: merging ? mergedContent ?? current.content : current.content, + title: previous + ? alternative.title + : merging + ? merge.title.resolve(metadataChoices[NotesMergeAttribute.title]) + : current.title, + category: previous + ? alternative.category + : merging + ? merge.category.resolve( + metadataChoices[NotesMergeAttribute.category], + ) + : current.category, + favorite: previous + ? alternative.favorite + : merging + ? merge.favorite.resolve( + metadataChoices[NotesMergeAttribute.favorite], + ) + : current.favorite, + metadataConflict: null, + base: !serverChanged && fresh != null ? fresh : current.base, + remote: serverChanged ? fresh : null, + readonly: fresh?.readonly ?? current.readonly, + revision: current.revision + 1, + localActivityMicros: clock().microsecondsSinceEpoch, + syncState: protectedState + ? current.syncState + : current.serverId == null && current.creationAttempt != null + ? NoteSyncState.creationUncertain + : serverChanged + ? NoteSyncState.conflict + : NoteSyncState.pending, + errorMessage: protectedState + ? current.errorMessage + : serverChanged + ? 'The server also changed. Review its state against your selected properties.' + : null, + ); + if (fresh?.readonly == true && _readonlyBlocks(resolved, fresh!)) { + throw const NotesException( + NotesFailureCode.forbidden, + 'The server note is read-only. Recover protected changes as a new note.', + ); + } + await store.saveNote(resolved); + _notes[current.localId] = resolved; + _notify(); + }); + } + Future _resolveCreation( NextcloudNote original, NotesCreationReview review, @@ -1445,6 +2880,7 @@ class NotesRepository { final deleted = note.copyWith( syncState: NoteSyncState.deletedRemotely, ackRevision: expectedRevision, + deletionEvidence: 'deliberate', errorMessage: note.revision > expectedRevision ? 'The remote note was deleted, but newer local edits are preserved for recovery.' : null, @@ -1458,8 +2894,9 @@ class NotesRepository { String localId, { required String filename, required Uint8List bytes, - }) async { + }) => _mutate(() async { final note = _require(localId); + _assertMutableAccount(note.accountId); if (note.readonly || note.error) { throw const NotesException( NotesFailureCode.forbidden, @@ -1485,7 +2922,7 @@ class NotesRepository { await store.saveAttachment(attachment, bytes); _notify(); return attachment; - } + }); Future _publishAttachments( String localId, @@ -1498,6 +2935,7 @@ class NotesRepository { if (storedAttachment == null) continue; var attachment = storedAttachment; final note = _require(localId); + if (_isBlocked(note)) return; if (attachment.state == 'deleted') continue; if (attachment.state == 'deletePending') { try { @@ -1559,20 +2997,22 @@ class NotesRepository { reference: attachment.reference, remotePath: path, state: 'uploaded', + validatedAt: clock(), ); + await store.updateAttachment(attachment); } on NotesException catch (error) { - final uncertain = - error.code == NotesFailureCode.network || - error.code == NotesFailureCode.server || - error.code == NotesFailureCode.invalidResponse || - error.code == NotesFailureCode.locked; + final uncertain = error.possiblyExecuted; await store.updateAttachment( NotesAttachment( id: attachment.id, noteId: localId, filename: attachment.filename, reference: attachment.reference, - state: uncertain ? 'uncertain' : 'pending', + state: uncertain + ? 'uncertain' + : error.retryable + ? 'pending' + : 'rejected', ), ); rethrow; @@ -1592,7 +3032,9 @@ class NotesRepository { final updated = current.copyWith( content: content, revision: current.revision + 1, - syncState: NoteSyncState.pending, + syncState: current.metadataConflict != null + ? NoteSyncState.conflict + : NoteSyncState.pending, ); await store.updateAttachment(attachment, note: updated); (_publishedReferences[localId] ??= {})[attachment.reference] = @@ -1621,16 +3063,36 @@ class NotesRepository { }); } + Future resolveCachedMedia( + String accountId, + String localId, + String reference, + ) => _resolveMedia( + accountId, + localId, + reference, + canonicalAttachmentReference(reference), + allowDownload: false, + ); + Future _resolveMedia( String accountId, String localId, String reference, - String? canonicalReference, - ) async { + String? canonicalReference, { + bool allowDownload = true, + }) async { _validateId(accountId); _validateId(localId); final note = _notes[localId]; - if (note == null || note.accountId != accountId) return null; + if (note == null || + note.accountId != accountId || + _disposed || + _removing.contains(accountId)) { + return null; + } + final accountGeneration = this.accountGeneration(accountId); + final freshnessEpoch = _mediaFreshnessEpochs[localId] ?? 0; bool matches(NotesAttachment candidate) => candidate.reference == reference || (canonicalReference != null && @@ -1639,27 +3101,74 @@ class NotesRepository { final initialAttachment = _liveMediaAttachment(initial); if (initial.isNotEmpty && initialAttachment == null) return null; File? fetched; - if (initialAttachment == null) { - if (canonicalReference == null || note.serverId == null) return null; - final client = await _clientForAccount(_accounts[accountId]!); - fetched = await _downloadAttachment( - client, - note.serverId!, - canonicalReference, - ); + final downloadable = + canonicalReference != null && + note.serverId != null && + (initialAttachment == null || + ({'cached', 'uploaded'}.contains(initialAttachment.state) && + !(initialAttachment.state == 'uploaded' && + (note.hasPendingChanges || + !notesAttachmentReferences(note.content).any( + (r) => + canonicalAttachmentReference(r.reference) == + canonicalReference, + ))) && + !reference.startsWith('busymark-attachment:') && + !note.content.contains( + initialAttachment.reference.startsWith('busymark-attachment:') + ? initialAttachment.reference + : '\u0000', + ))); + final stale = + initialAttachment == null || + initialAttachment.validatedAt == null || + clock().difference(initialAttachment.validatedAt!) >= mediaFreshness || + (_validatedMediaEpochs[initialAttachment.id] ?? 0) < freshnessEpoch; + if (allowDownload && downloadable && stale) { + final cancellation = NotesDownloadCancellation(); + final key = '$accountId:$localId:$canonicalReference'; + _downloads[key] = cancellation; + try { + if (!_currentAccount(accountId, accountGeneration)) return null; + _checkThrottle(_accounts[accountId]!); + final client = await _clientForAccount(_accounts[accountId]!); + cancellation.check(); + if (!_currentAccount(accountId, accountGeneration)) return null; + fetched = await _downloadAttachment( + client, + note.serverId!, + canonicalReference, + cancellation: cancellation, + ); + } on NotesException catch (error) { + if (!_currentAccount(accountId, accountGeneration)) return null; + await _rememberThrottle(accountId, error); + if (initialAttachment == null) rethrow; + // Retain last usable bytes, without advancing their validation time. + } finally { + if (identical(_downloads[key], cancellation)) _downloads.remove(key); + } + } else if (initialAttachment == null) { + return null; } try { return await _mutate(() async { // Another context, publication or deletion may have committed during HTTP. // Never insert another owner for the same canonical destination or revive // a deletion tombstone. Network waits stay outside the durability queue. - if (_notes[localId]?.accountId != accountId) return null; + if (!_currentAccount(accountId, accountGeneration) || + _notes[localId]?.accountId != accountId || + _notes[localId]?.serverId != note.serverId || + _notes[localId]?.category != note.category || + (_mediaFreshnessEpochs[localId] ?? 0) != freshnessEpoch) { + return null; + } final current = (await store.attachments( localId, )).where(matches).toList(); final liveAttachment = _liveMediaAttachment(current); if (current.isNotEmpty && liveAttachment == null) return null; - final attachment = + var attachment = liveAttachment ?? NotesAttachment( id: _uuid.v4(), @@ -1669,9 +3178,24 @@ class NotesRepository { remotePath: canonicalReference, state: 'cached', ); - if (liveAttachment == null) { - if (fetched == null) return null; + if (fetched != null && + (liveAttachment == null || + {'cached', 'uploaded'}.contains(liveAttachment.state))) { + attachment = NotesAttachment( + id: attachment.id, + noteId: localId, + filename: attachment.filename, + reference: attachment.reference, + remotePath: attachment.remotePath, + state: attachment.state, + validatedAt: clock(), + ); await store.saveAttachmentFile(attachment, fetched); + _validatedMediaEpochs[attachment.id] = freshnessEpoch; + _mediaVersions[localId] = mediaVersion(localId) + 1; + _notify(); + } else if (liveAttachment == null) { + return null; } _validateId(attachment.id); final extension = p.extension(attachment.filename); @@ -1724,6 +3248,15 @@ class NotesRepository { } await partial.rename(file.path); } + // One complete materialization per attachment; content-addressed paths + // change renderer identity without accumulating superseded files. + await for (final entity in directory.list(followLinks: false)) { + if (entity is File && + entity.path != file.path && + p.basename(entity.path).startsWith('${attachment.id}-')) { + await entity.delete(); + } + } return file.path; } finally { await staging.delete(recursive: true); @@ -1737,8 +3270,9 @@ class NotesRepository { Future _downloadAttachment( NotesApiClient client, int id, - String path, - ) async { + String path, { + NotesDownloadCancellation? cancellation, + }) async { final root = Directory(p.join(p.dirname(store.path), 'downloads')); if (await FileSystemEntity.isLink(root.path)) { throw const NotesException( @@ -1759,6 +3293,7 @@ class NotesRepository { id, path, destination: File(p.join(directory.path, 'complete')), + cancellation: cancellation, ); } catch (_) { await directory.delete(recursive: true); @@ -1877,6 +3412,9 @@ class NotesRepository { } Future _invalidateAttachmentMedia(NotesAttachment attachment) async { + _downloads.entries + .where((e) => e.key.contains(':${attachment.noteId}:')) + .forEach((e) => e.value.cancel()); _mediaVersions[attachment.noteId] = mediaVersion(attachment.noteId) + 1; final note = _notes[attachment.noteId]; if (note == null) return; @@ -1996,6 +3534,12 @@ class NotesRepository { }) async { _validateId(accountId); _removing.add(accountId); + _accountGenerations[accountId] = (_accountGenerations[accountId] ?? 0) + 1; + for (final entry in _downloads.entries.where( + (e) => e.key.startsWith('$accountId:'), + )) { + entry.value.cancel(); + } try { await _syncs[accountId]; await _mutate(() async { @@ -2023,7 +3567,16 @@ class NotesRepository { Future _dispose() async { _disposed = true; - await Future.wait(_syncs.values); + for (final cancellation in _downloads.values) { + cancellation.cancel(); + } + await Future.wait([ + ..._syncs.values, + ..._capabilityRequests.values, + ..._mediaResolutions.values.map( + (f) => f.then((_) {}, onError: (Object _, StackTrace _) {}), + ), + ]); await _mutations; await _changes.close(); await store.close(); diff --git a/lib/src/nextcloud_notes/application/notes_search_controller.dart b/lib/src/nextcloud_notes/application/notes_search_controller.dart new file mode 100644 index 00000000..890bdbca --- /dev/null +++ b/lib/src/nextcloud_notes/application/notes_search_controller.dart @@ -0,0 +1,186 @@ +import 'dart:async'; +import 'dart:isolate'; + +import '../data/notes_store.dart'; +import '../domain/notes_search.dart'; + +class NotesSearchOverlay { + const NotesSearchOverlay( + this.localId, + this.revision, + this.title, + this.category, + this.source, + ); + final String localId, title, category, source; + final int revision; +} + +class NotesSearchState { + const NotesSearchState({ + this.hits = const [], + this.loading = false, + this.indexing = false, + this.truncated = false, + this.error, + }); + final List hits; + final bool loading, indexing, truncated; + final Object? error; +} + +/// One bounded query workflow for the Notes sidebar and workspace command. +class NotesSearchController { + NotesSearchController(this.store, this.accountId, {this.recovery = false}); + final NotesStore store; + final String accountId; + final bool recovery; + final _changes = StreamController.broadcast(); + Stream get changes => _changes.stream; + NotesSearchState state = const NotesSearchState(); + int _generation = 0; + bool _disposed = false; + void _publish(NotesSearchState value) { + if (!_disposed) { + state = value; + _changes.add(value); + } + } + + void cancel() { + _generation++; + } + + Future search( + String text, { + bool wholeWord = false, + int limit = 80, + List overlays = const [], + }) async { + final generation = ++_generation; + bool current() => !_disposed && generation == _generation; + _publish(const NotesSearchState(loading: true, indexing: true)); + try { + final query = NotesSearchQuery(text, wholeWord: wholeWord); + while (await store.indexStep() != 0) { + if (!current()) return; + } + if (!current()) return; + _publish(const NotesSearchState(loading: true)); + final hits = await _searchOverlays( + query, + recovery ? const [] : overlays, + limit + 1, + ); + var after = 0; + Map? continuation; + var more = true; + while (more && hits.length <= limit) { + if (!current()) return; + final chunk = await store.searchChunk( + accountId, + query, + after: after, + continuation: continuation, + recovery: recovery, + limit: limit + 1 - hits.length, + exclude: recovery ? const {} : overlays.map((o) => o.localId).toSet(), + ); + hits.addAll( + (chunk['hits'] as List).map( + (h) => NotesSearchHit.fromJson(Map.from(h as Map)), + ), + ); + after = chunk['after'] as int; + more = chunk['more'] as bool; + continuation = chunk['continuation'] == null + ? null + : Map.from(chunk['continuation'] as Map); + } + if (!current()) return; + hits.sort((a, b) { + final title = a.title.toLowerCase().compareTo(b.title.toLowerCase()); + if (title != 0) return title; + final id = a.localId.compareTo(b.localId); + if (id != 0) return id; + final start = (a.start ?? -1).compareTo(b.start ?? -1); + return start != 0 ? start : (a.end ?? -1).compareTo(b.end ?? -1); + }); + _publish( + NotesSearchState( + hits: List.unmodifiable(hits.take(limit)), + truncated: hits.length > limit || more, + ), + ); + } on Object catch (error) { + if (current()) _publish(NotesSearchState(error: error)); + } + } + + Future dispose() async { + _disposed = true; + _generation++; + await _changes.close(); + } +} + +Future> _searchOverlays( + NotesSearchQuery query, + List overlays, + int limit, +) => Isolate.run(() { + final hits = []; + for (final overlay in overlays) { + hits.addAll( + matchNotesDocument( + query: query, + localId: overlay.localId, + revision: overlay.revision, + title: overlay.title, + category: overlay.category, + source: overlay.source, + limit: limit - hits.length, + ), + ); + if (hits.length == limit) break; + } + return hits; +}); + +/// Navigation after an edit searches through bounded occurrence pages instead +/// of relocating a late result to one of the first page's unrelated ranges. +Future relocateNotesSearchHit( + NotesSearchQuery query, + NotesSearchOverlay document, + int nearOffset, +) => Isolate.run(() { + NotesSearchHit? nearest; + var start = -1, end = -1; + while (true) { + final page = matchNotesDocument( + query: query, + localId: document.localId, + revision: document.revision, + title: document.title, + category: document.category, + source: document.source, + limit: 200, + afterStart: start, + afterEnd: end, + ); + for (final hit in page) { + if (nearest == null || + ((hit.start ?? 0) - nearOffset).abs() < + ((nearest.start ?? 0) - nearOffset).abs()) { + nearest = hit; + } + } + if (page.isEmpty || + page.length < 200 || + (page.last.start ?? 0) >= nearOffset) { + return nearest; + } + start = page.last.start!; + end = page.last.end!; + } +}); diff --git a/lib/src/nextcloud_notes/application/notes_settings_controller.dart b/lib/src/nextcloud_notes/application/notes_settings_controller.dart new file mode 100644 index 00000000..316d6046 --- /dev/null +++ b/lib/src/nextcloud_notes/application/notes_settings_controller.dart @@ -0,0 +1,181 @@ +import 'dart:async'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../domain/notes_models.dart'; +import 'nextcloud_connection.dart'; + +enum NotesSettingsPhase { loading, ready, saving, saved, failed } + +class NotesSettingsState { + const NotesSettingsState({ + this.accountId, + this.original, + this.draft, + this.phase = NotesSettingsPhase.loading, + this.error, + this.normalized = false, + }); + final String? accountId; + final NotesSettings? original; + final NotesSettings? draft; + final NotesSettingsPhase phase; + final String? error; + final bool normalized; + Map get patch => { + if (draft != null && + original != null && + draft!.notesPath != original!.notesPath) + 'notesPath': draft!.notesPath, + if (draft != null && + original != null && + draft!.fileSuffix != original!.fileSuffix) + 'fileSuffix': draft!.fileSuffix, + }; + bool get dirty => patch.isNotEmpty; + bool get busy => + phase == NotesSettingsPhase.loading || phase == NotesSettingsPhase.saving; +} + +final notesSettingsProvider = + NotifierProvider.autoDispose( + NotesSettingsController.new, + ); + +class NotesSettingsController extends Notifier { + int _generation = 0; + bool _disposed = false; + @override + NotesSettingsState build() { + ref.onDispose(() { + _disposed = true; + _generation++; + }); + return const NotesSettingsState(); + } + + Future load(String id, {bool discard = false}) async { + if (state.accountId == id && + (state.phase == NotesSettingsPhase.saving || + (state.dirty && !discard))) { + return; + } + final generation = ++_generation; + state = NotesSettingsState(accountId: id); + try { + final repository = await ref.read( + nextcloudNotesRepositoryProvider.future, + ); + await repository.maintainCapabilities(id); + if (_disposed || + generation != _generation || + repository.accountById(id) == null) { + return; + } + final settings = await repository.reconcileSettings(id); + if (_disposed || + generation != _generation || + repository.accountById(id) == null) { + return; + } + state = NotesSettingsState( + accountId: id, + original: settings, + draft: settings, + phase: NotesSettingsPhase.ready, + ); + } on NotesException catch (error) { + if (_disposed || generation != _generation) return; + state = NotesSettingsState( + accountId: id, + phase: NotesSettingsPhase.failed, + error: error.message, + ); + } + } + + void edit({String? notesPath, String? fileSuffix}) { + final draft = state.draft; + if (draft == null || state.busy) return; + state = NotesSettingsState( + accountId: state.accountId, + original: state.original, + draft: NotesSettings( + notesPath: notesPath ?? draft.notesPath, + fileSuffix: fileSuffix ?? draft.fileSuffix, + ), + phase: NotesSettingsPhase.ready, + ); + } + + void cancel() { + if (state.busy) return; + state = NotesSettingsState( + accountId: state.accountId, + original: state.original, + draft: state.original, + phase: NotesSettingsPhase.ready, + ); + } + + Future save({required Future Function() preserveBuffers}) async { + final id = state.accountId; + final original = state.original; + final patch = state.patch; + if (id == null || original == null || patch.isEmpty || state.busy) { + return false; + } + final generation = ++_generation; + final draft = state.draft; + state = NotesSettingsState( + accountId: id, + original: original, + draft: draft, + phase: NotesSettingsPhase.saving, + ); + try { + if (!await preserveBuffers()) { + if (!_disposed && generation == _generation) { + state = NotesSettingsState( + accountId: id, + original: original, + draft: draft, + phase: NotesSettingsPhase.failed, + ); + } + return false; + } + if (_disposed || generation != _generation) return false; + final repository = await ref.read( + nextcloudNotesRepositoryProvider.future, + ); + final result = await repository.changeSettings(id, original, patch); + if (_disposed || + generation != _generation || + repository.accountById(id) == null) { + return false; + } + state = NotesSettingsState( + accountId: id, + original: result, + draft: result, + phase: NotesSettingsPhase.saved, + normalized: patch.entries.any((e) => result.toJson()[e.key] != e.value), + ); + // Checkpoint invalidation is durable before this authoritative read. + await repository.synchronize(id, allowWrites: false); + return true; + } on NotesException catch (error) { + if (!_disposed && generation == _generation) { + state = NotesSettingsState( + accountId: id, + original: original, + draft: draft, + phase: NotesSettingsPhase.failed, + error: error.message, + ); + } + return false; + } + } +} diff --git a/lib/src/nextcloud_notes/application/notes_sync_coordinator.dart b/lib/src/nextcloud_notes/application/notes_sync_coordinator.dart new file mode 100644 index 00000000..a1156f6e --- /dev/null +++ b/lib/src/nextcloud_notes/application/notes_sync_coordinator.dart @@ -0,0 +1,270 @@ +import 'dart:async'; + +import '../domain/notes_models.dart'; +import 'notes_repository.dart'; + +enum NotesSyncTrigger { + opening, + localChange, + manual, + focus, + poll, + retry, + recovery, +} + +abstract interface class NotesScheduledTask { + void cancel(); +} + +class _TimerTask implements NotesScheduledTask { + _TimerTask(Duration delay, void Function() callback) + : timer = Timer(delay, callback); + final Timer timer; + @override + void cancel() => timer.cancel(); +} + +typedef NotesSchedule = NotesScheduledTask Function(Duration, void Function()); + +/// Scheduling only. The repository remains the sole synchronization engine. +class NotesSyncCoordinator { + NotesSyncCoordinator( + this.repository, { + DateTime Function()? clock, + NotesSchedule? schedule, + this.onError, + }) : clock = clock ?? DateTime.now, + schedule = schedule ?? _TimerTask.new; + final NotesRepository repository; + final DateTime Function() clock; + final NotesSchedule schedule; + final void Function(Object, StackTrace)? onError; + static const pollInterval = Duration(seconds: 60); + static const focusCoalescing = Duration(seconds: 10); + static const retryIntervals = [5, 10, 20, 40, 80, 160]; + String? _account; + bool _visible = true; + bool _disposed = false; + int _generation = 0; + Future? _running; + String? _runningAccount; + bool _followup = false; + bool _manual = false; + bool _writes = false; + bool _capabilities = false; + bool _readWasOffline = false; + int _accountRetryCount = 0; + DateTime? _completed; + NotesScheduledTask? _poll; + NotesScheduledTask? _retry; + + void setActiveAccount(String? id) { + if (_disposed || id == _account) return; + _generation++; + _account = id; + _poll?.cancel(); + _poll = null; + _retry?.cancel(); + _retry = null; + _followup = false; + _manual = false; + _writes = false; + _capabilities = false; + _completed = null; + _accountRetryCount = 0; + _readWasOffline = false; + _armPoll(); + } + + void setVisible(bool visible) { + if (_disposed || visible == _visible) return; + _visible = visible; + _poll?.cancel(); + _poll = null; + if (visible) { + _background(NotesSyncTrigger.focus); + _armPoll(); + } + } + + void focused() => _background(NotesSyncTrigger.focus); + + Future request(NotesSyncTrigger trigger) { + if (_disposed || _account == null) return Future.value(); + if (_running != null && _runningAccount != _account) { + final generation = _generation; + return _running!.then( + (_) => generation == _generation && !_disposed + ? request(trigger) + : Future.value(), + ); + } + if ((trigger == NotesSyncTrigger.focus || + trigger == NotesSyncTrigger.poll) && + !_visible) { + return Future.value(); + } + if (trigger == NotesSyncTrigger.focus && + _completed != null && + clock().difference(_completed!) < focusCoalescing) { + return _running ?? Future.value(); + } + final writes = { + NotesSyncTrigger.opening, + NotesSyncTrigger.localChange, + NotesSyncTrigger.manual, + NotesSyncTrigger.retry, + }.contains(trigger); + _writes |= writes; + _manual |= trigger == NotesSyncTrigger.manual; + _capabilities |= { + NotesSyncTrigger.opening, + NotesSyncTrigger.manual, + NotesSyncTrigger.recovery, + }.contains(trigger); + if (_running != null) { + if (writes) { + _followup = true; + } + return _running!; + } + final id = _account!; + final generation = _generation; + _runningAccount = id; + late final Future task; + task = _run(id, generation).whenComplete(() { + if (identical(_running, task)) { + _running = null; + _runningAccount = null; + } + if (_disposed) return; + if (generation == _generation) { + _armRetry(); + _armPoll(); + } + }); + _running = task; + return task; + } + + Future _run(String id, int generation) async { + do { + _followup = false; + final manual = _manual; + final writes = _writes; + final capabilities = _capabilities; + _manual = false; + _writes = false; + _capabilities = false; + if (manual) { + _accountRetryCount = 0; + await repository.retryWrites(id); + } + await repository.synchronize( + id, + allowWrites: true, + onlyFreshWrites: !writes, + refreshCapabilities: capabilities, + ); + if (_disposed || + generation != _generation || + repository.accountById(id) == null) { + return; + } + final error = repository.accountError(id); + if (error?.code == NotesFailureCode.network) _readWasOffline = true; + if (error == null) { + _completed = clock(); + if (_readWasOffline) { + _readWasOffline = false; + _accountRetryCount = 0; + await repository.recoverNetworkWrites(id); + _writes = true; + _capabilities = true; + _followup = true; + } + } + await repository.refreshDisplayedMedia(force: manual); + } while (_followup && !_disposed && generation == _generation); + } + + void _armPoll() { + if (_disposed || !_visible || _account == null || _poll != null) return; + final generation = _generation; + _poll = schedule(pollInterval, () { + _poll = null; + if (_disposed || generation != _generation) return; + // Authenticated collection reads are also bounded connectivity probes. + _background( + _readWasOffline ? NotesSyncTrigger.recovery : NotesSyncTrigger.poll, + ); + _armPoll(); + }); + } + + void _armRetry() { + final id = _account; + if (_disposed || id == null || _retry != null) return; + final accountFailure = repository.accountError(id); + final accountRetry = + accountFailure?.retryable == true && + _accountRetryCount < retryIntervals.length; + final retryable = repository.hasRetryableWork(id); + if (!accountRetry && !retryable) return; + final counts = + repository.notes + .where( + (n) => + n.accountId == id && + n.failureCode != null && + n.retryCount > 0 && + n.retryCount <= 6 && + { + NotesFailureCode.network, + NotesFailureCode.server, + NotesFailureCode.locked, + NotesFailureCode.throttled, + }.contains(n.failureCode), + ) + .map((n) => n.retryCount - 1) + .toList() + ..sort(); + final attempt = accountRetry + ? _accountRetryCount++ + : counts.firstOrNull ?? 0; + var delay = Duration(seconds: retryIntervals[attempt.clamp(0, 5)]); + final deadline = repository.writeDeadline(id); + if (deadline != null && deadline.difference(clock()) > delay) { + delay = deadline.difference(clock()); + } + final generation = _generation; + _retry = schedule(delay, () { + _retry = null; + if (!_disposed && generation == _generation) { + _background(NotesSyncTrigger.retry); + } + }); + } + + void _background(NotesSyncTrigger trigger) { + unawaited( + request(trigger).catchError((Object error, StackTrace stack) { + onError?.call(error, stack); + }), + ); + } + + void removeAccount(String id) { + if (_account == id) setActiveAccount(null); + } + + void dispose() { + _disposed = true; + _generation++; + _poll?.cancel(); + _retry?.cancel(); + _account = null; + _followup = false; + } +} diff --git a/lib/src/nextcloud_notes/application/notes_transfer_service.dart b/lib/src/nextcloud_notes/application/notes_transfer_service.dart new file mode 100644 index 00000000..200942bd --- /dev/null +++ b/lib/src/nextcloud_notes/application/notes_transfer_service.dart @@ -0,0 +1,667 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:crypto/crypto.dart'; +import 'package:path/path.dart' as p; +import 'package:uuid/uuid.dart'; + +import '../../assets/asset_limits.dart'; +import '../../assets/document_media_resolver.dart'; +import '../../export/markdown_copy_export_service.dart'; +import '../data/notes_attachment_references.dart'; +import '../domain/notes_models.dart'; +import '../domain/notes_search.dart' show normalizeNotesSearch; +import 'notes_repository.dart'; + +class NotesTransferCancellation { + bool cancelled = false; + void cancel() => cancelled = true; + void check() { + if (cancelled) throw const FileSystemException('Transfer cancelled.'); + } +} + +class NotesExportResult { + const NotesExportResult(this.path, this.count, this.omissions); + final String path; + final int count; + final List omissions; + bool get complete => omissions.isEmpty; +} + +class NotesImportItem { + NotesImportItem({ + required this.key, + required this.path, + required this.title, + required this.category, + required this.content, + this.favorite = false, + this.activityMicros, + this.serverModified, + this.collision = false, + this.media = const {}, + this.issues = const [], + }); + final String key, path, title, content; + String category; + final bool favorite, collision; + final int? activityMicros, serverModified; + final Map media; + final List issues; + bool selected = true; + String? importedNoteId; + bool get alreadyImported => importedNoteId != null; +} + +class NotesImportReview { + NotesImportReview(this.root, this.items, this.issues, {String? operationId}) + : operationId = operationId ?? const Uuid().v4(); + final String root, operationId; + final List items; + final List issues; +} + +class NotesImportOutcome { + const NotesImportOutcome( + this.item, { + this.noteId, + this.error, + this.alreadyImported = false, + }); + final String item; + final String? noteId, error; + final bool alreadyImported; +} + +/// Portable snapshots contain document metadata only. Every note is an +/// ordinary Markdown document; local identities, credentials and outbox are +/// deliberately absent from the versioned interchange manifest. +class NotesTransferService { + const NotesTransferService(this.repository); + final NotesRepository repository; + static const manifestName = 'busymark-notes.json'; + static const format = 'busymark-notes'; + static const version = 1; + static const maximumDocumentBytes = 4 * 1024 * 1024; + + Future exportSnapshot({ + required List notes, + required String destination, + required NotesTransferCancellation cancellation, + void Function(int completed, int total)? onProgress, + }) async { + final snapshot = notes is NotesLocalSnapshot + ? notes + : NotesLocalSnapshot( + List.unmodifiable(notes), + mediaVersions: { + for (final note in notes) + note.localId: repository.mediaVersion(note.localId), + }, + accountGenerations: { + for (final note in notes) + note.accountId: repository.accountGeneration(note.accountId), + }, + ); + void checkSnapshot() { + cancellation.check(); + if (snapshot.mediaVersions.entries.any( + (e) => repository.mediaVersion(e.key) != e.value, + ) || + snapshot.accountGenerations.entries.any( + (e) => repository.accountGeneration(e.key) != e.value, + )) { + throw const FileSystemException( + 'Export media changed; capture the snapshot again.', + ); + } + } + + checkSnapshot(); + final parent = Directory(destination); + await _safeAbsolute(parent.path); + final staging = await parent.createTemp('.busymark-export-'); + final published = p.join( + parent.path, + 'BusyMark-notes-${const Uuid().v4()}', + ); + final documents = >[]; + final omissions = []; + final directories = {'': ''}; + final used = {}; + try { + for (final note in snapshot) { + checkSnapshot(); + var directory = ''; + var original = ''; + for (final component + in note.category.split('/').where((s) => s.isNotEmpty)) { + original = original.isEmpty ? component : '$original/$component'; + directory = directories.putIfAbsent( + original, + () => p.join( + directory, + _uniqueName(component, used, 'entry:$directory'), + ), + ); + } + final name = _uniqueName( + note.title, + used, + 'entry:$directory', + extension: '.md', + ); + final relative = p.join(directory, name); + final file = File(p.join(staging.path, relative)); + await file.parent.create(recursive: true); + final missing = []; + await const MarkdownCopyExportService().export( + source: note.content, + destinationPath: file.path, + media: DocumentMediaContext( + identity: note.localId, + resolve: (r) => + repository.resolveCachedMedia(note.accountId, note.localId, r), + resolveCached: (_) => null, + ), + onMissing: missing.add, + isCancelled: () => cancellation.cancelled, + ); + final availability = await repository.attachmentAvailability( + note.localId, + source: note.content, + ); + missing.addAll(availability.external); + for (final reference in missing) { + omissions.add('${note.title}: $reference'); + } + documents.add({ + 'file': p.split(relative).join('/'), + 'title': note.title, + 'category': note.category, + 'favorite': note.favorite, + 'activityMicros': note.activityMicros, + 'serverModified': note.modified, + 'workingVersion': true, + 'unresolvedConflict': + note.metadataConflict != null || + note.syncState == NoteSyncState.conflict || + note.syncState == NoteSyncState.deletedRemotely && + note.hasPendingChanges, + 'omissions': missing, + }); + checkSnapshot(); + onProgress?.call(documents.length, snapshot.length); + } + checkSnapshot(); + await File(p.join(staging.path, manifestName)).writeAsString( + const JsonEncoder.withIndent(' ').convert({ + 'format': format, + 'version': version, + 'capturedAt': DateTime.now().toUtc().toIso8601String(), + 'documents': documents, + 'complete': omissions.isEmpty, + }), + flush: true, + ); + checkSnapshot(); + if (await FileSystemEntity.type(published, followLinks: false) != + FileSystemEntityType.notFound) { + throw const FileSystemException('Export destination already exists.'); + } + await staging.rename(published); + return NotesExportResult( + published, + documents.length, + List.unmodifiable(omissions), + ); + } finally { + if (await staging.exists()) await staging.delete(recursive: true); + } + } + + Future review( + String source, + String accountId, { + String category = '', + NotesTransferCancellation? cancellation, + }) async { + await _safeAbsolute(source); + final type = await FileSystemEntity.type(source, followLinks: false); + final root = type == FileSystemEntityType.directory + ? p.normalize(p.absolute(source)) + : p.dirname(p.absolute(source)); + final manifest = File(p.join(root, manifestName)); + final issues = []; + final entries = >[]; + if (type == FileSystemEntityType.directory && await manifest.exists()) { + await _within(root, manifest.path); + if (await manifest.length() > maximumDocumentBytes) { + throw const FormatException('Manifest is too large.'); + } + final data = + jsonDecode(await manifest.readAsString()) as Map; + if (data['format'] != format || + data['version'] != version || + data['documents'] is! List) { + throw const FormatException('Unsupported Notes snapshot manifest.'); + } + for (final item in data['documents'] as List) { + final entry = Map.from(item as Map); + if (entry['file'] is! String || + entry['title'] is! String || + entry['category'] is! String || + entry['favorite'] is! bool) { + throw const FormatException('Invalid Notes snapshot entry.'); + } + entries.add(entry); + } + } else if (type == FileSystemEntityType.file) { + if (!_markdown(source)) { + throw const FormatException('Choose Markdown files.'); + } + entries.add({ + 'file': p.basename(source), + 'title': p.basenameWithoutExtension(source), + 'category': category, + }); + } else if (type == FileSystemEntityType.directory) { + await for (final entity in Directory( + root, + ).list(recursive: true, followLinks: false)) { + cancellation?.check(); + if (entity is Link) { + issues.add( + 'Symbolic link skipped: ${p.relative(entity.path, from: root)}', + ); + continue; + } + if (entity is File && _markdown(entity.path)) { + final relative = p.relative(entity.path, from: root); + final parent = p.dirname(relative); + entries.add({ + 'file': p.split(relative).join('/'), + 'title': p.basenameWithoutExtension(relative), + 'category': [ + category, + if (parent != '.') p.split(parent).join('/'), + ].where((s) => s.isNotEmpty).join('/'), + }); + } + } + } else { + throw const FileSystemException('Import source is unavailable.'); + } + entries.sort( + (a, b) => (a['file'] as String).compareTo(b['file'] as String), + ); + final items = []; + final paths = {}; + for (final entry in entries) { + cancellation?.check(); + if (!_markdown(entry['file'] as String)) { + throw const FormatException( + 'Snapshot entries must be Markdown documents.', + ); + } + final path = _relative(root, entry['file'] as String); + if (!paths.add(path)) { + throw const FormatException('Duplicate manifest file mapping.'); + } + await _within(root, path); + final file = File(path); + if (await file.length() > maximumDocumentBytes) { + issues.add('Document too large: ${entry['file']}'); + continue; + } + final content = await file.readAsString(); + final media = {}; + final itemIssues = []; + for (final occurrence in await scanNotesAttachmentReferences(content)) { + final reference = occurrence.reference; + final uri = Uri.tryParse(reference); + if (reference.startsWith('#') || + uri?.scheme == 'https' || + uri?.scheme == 'http') { + if (occurrence.image) itemIssues.add('External image: $reference'); + continue; + } + if (uri?.hasScheme == true || + reference.startsWith('//') || + reference.contains('\\')) { + itemIssues.add('Unsupported reference: $reference'); + continue; + } + // Preserve unrelated document links. Import only recognized image and + // snapshot companion-media destinations, through the shared scanner. + final decoded = _localMediaDestination(reference); + if (decoded == null) { + if (occurrence.image) itemIssues.add('Unsafe reference: $reference'); + continue; + } + if (!occurrence.image && !decoded.contains('.attachments-')) continue; + try { + final asset = p.normalize( + p.join(file.parent.path, p.joinAll(decoded.split('/'))), + ); + await _within(root, asset); + final assetFile = File(asset); + if (await assetFile.length() > maximumManagedAssetBytes) { + throw const FileSystemException('Media exceeds size limit.'); + } + final digest = await sha256.bind(assetFile.openRead()).first; + media[reference] = ( + path: asset, + filename: p.basename(asset), + digest: digest.toString(), + ); + } on Object { + itemIssues.add('Missing or unsafe media: $reference'); + } + } + final targetCategory = entry['category'] as String; + final title = entry['title'] as String; + final collision = repository.notes.any( + (n) => + n.accountId == accountId && + n.title == title && + n.category == targetCategory && + n.syncState != NoteSyncState.deletedRemotely, + ); + final digest = sha256.convert(utf8.encode(content)).toString(); + // This is an item fingerprint, not an account-wide import identity. The + // durable reviewed operation namespaces it when an import is applied. + final key = sha256 + .convert(utf8.encode('$root\u0000${entry['file']}\u0000$digest')) + .toString(); + items.add( + NotesImportItem( + key: key, + path: path, + title: title, + category: targetCategory, + content: content, + favorite: entry['favorite'] as bool? ?? false, + activityMicros: entry['activityMicros'] as int?, + serverModified: entry['serverModified'] as int?, + collision: collision, + media: media, + issues: itemIssues, + ), + ); + } + return NotesImportReview(root, items, issues); + } + + String _itemKey(NotesImportReview review, NotesImportItem item) => + '${review.operationId}:${item.key}'; + + /// Restart resumes a specific reviewed operation, with the original paths, + /// fingerprints, selection and categories. A new review always gets a new ID. + Future resumeImport( + String operationId, + String accountId, + ) async { + final operation = await repository.store.importOperation( + accountId, + operationId, + ); + if (operation == null) throw StateError('Import operation is unavailable.'); + final data = + jsonDecode(operation['review'] as String) as Map; + final review = NotesImportReview( + operation['root'] as String, + [ + for (final row in data['items'] as List) + NotesImportItem( + key: row['key'] as String, + path: row['path'] as String, + title: row['title'] as String, + category: row['category'] as String, + content: row['content'] as String, + favorite: row['favorite'] as bool, + activityMicros: row['activityMicros'] as int?, + serverModified: row['serverModified'] as int?, + collision: row['collision'] as bool, + media: { + for (final entry in (row['media'] as Map).entries) + entry.key as String: ( + path: entry.value['path'] as String, + filename: entry.value['filename'] as String, + digest: entry.value['digest'] as String, + ), + }, + issues: (row['issues'] as List).cast(), + )..selected = row['selected'] as bool, + ], + (data['issues'] as List).cast(), + operationId: operationId, + ); + for (final item in review.items) { + item.importedNoteId = await repository.store.importedNote( + accountId, + _itemKey(review, item), + ); + } + return review; + } + + String _reviewJson(NotesImportReview review) => jsonEncode({ + 'issues': review.issues, + 'items': [ + for (final item in review.items) + { + 'key': item.key, + 'path': item.path, + 'title': item.title, + 'category': item.category, + 'content': item.content, + 'favorite': item.favorite, + 'activityMicros': item.activityMicros, + 'serverModified': item.serverModified, + 'collision': item.collision, + 'selected': item.selected, + 'issues': item.issues, + 'media': { + for (final entry in item.media.entries) + entry.key: { + 'path': entry.value.path, + 'filename': entry.value.filename, + 'digest': entry.value.digest, + }, + }, + }, + ], + }); + + Future> importReviewed( + NotesImportReview review, + String accountId, { + required NotesTransferCancellation cancellation, + void Function(int completed, int total)? onProgress, + }) async { + if (cancellation.cancelled) return []; + await repository.store.beginImportOperation( + accountId, + review.operationId, + review.root, + _reviewJson(review), + ); + final outcomes = []; + final selected = review.items.where((i) => i.selected).toList(); + for (final item in selected) { + if (cancellation.cancelled) break; + try { + final existing = await repository.store.importedNote( + accountId, + _itemKey(review, item), + ); + if (existing != null) { + outcomes.add( + NotesImportOutcome( + item.title, + noteId: existing, + alreadyImported: true, + ), + ); + onProgress?.call(outcomes.length, selected.length); + continue; + } + await _within(review.root, item.path); + final file = File(item.path); + if (await file.length() > maximumDocumentBytes || + await file.readAsString() != item.content) { + throw const FileSystemException('Document changed after review.'); + } + final media = {}; + var total = 0; + for (final entry in item.media.entries) { + cancellation.check(); + await _within(review.root, entry.value.path); + final asset = File(entry.value.path); + final length = await asset.length(); + total += length; + if (length > maximumManagedAssetBytes || total > 256 * 1024 * 1024) { + throw const FileSystemException('Media exceeds import size limit.'); + } + final bytes = await asset.readAsBytes(); + if (sha256.convert(bytes).toString() != entry.value.digest) { + throw const FileSystemException('Media changed after review.'); + } + media[entry.key] = (filename: entry.value.filename, bytes: bytes); + } + cancellation.check(); + final note = await repository.importLocal( + accountId: accountId, + sourceKey: _itemKey(review, item), + title: item.title, + category: item.category, + content: item.content, + favorite: item.favorite, + activityMicros: item.activityMicros, + modified: item.serverModified, + media: media, + ); + item.importedNoteId = note.localId; + outcomes.add(NotesImportOutcome(item.title, noteId: note.localId)); + } on Object catch (error) { + outcomes.add(NotesImportOutcome(item.title, error: error.toString())); + } + onProgress?.call(outcomes.length, selected.length); + } + if (outcomes.length == selected.length && + outcomes.every((o) => o.error == null)) { + await repository.store.completeImportOperation( + accountId, + review.operationId, + [for (final item in selected) _itemKey(review, item)], + ); + } + return outcomes; + } + + // Local document destinations may contain dot segments. Decode once before + // resolving relative to the document, then enforce the selected-root/symlink + // boundary in _within. Remote attachment validation remains deliberately strict. + static String? _localMediaDestination(String reference) { + final uri = Uri.tryParse(reference); + if (uri == null || + uri.hasScheme || + uri.hasAuthority || + uri.hasQuery || + uri.hasFragment || + reference.contains('\\') || + RegExp(r'%(?![0-9a-fA-F]{2})').hasMatch(reference)) { + return null; + } + try { + final encoded = reference.replaceAllMapped( + RegExp(r'[^\x00-\x7f]+'), + (match) => Uri.encodeComponent(match[0]!), + ); + final decoded = Uri.decodeComponent(encoded); + if (decoded.isEmpty || + decoded.startsWith('/') || + decoded.contains('\\') || + RegExp(r'[\x00-\x1f\x7f]').hasMatch(decoded) || + decoded.split('/').first.contains(':')) { + return null; + } + return decoded; + } on FormatException { + return null; + } + } + + static bool _markdown(String path) => + {'.md', '.markdown'}.contains(p.extension(path).toLowerCase()); + static String _relative(String root, String relative) { + if (p.isAbsolute(relative) || + relative.contains('\\') || + relative.contains('\u0000') || + relative.split('/').any((s) => s == '..' || s.isEmpty)) { + throw const FormatException('Unsafe import path.'); + } + final path = p.normalize(p.join(root, relative)); + if (!p.isWithin(root, path)) { + throw const FormatException('Import path escapes source.'); + } + return path; + } + + static Future _within(String root, String path) async { + if (!p.isWithin(root, path)) { + throw const FileSystemException('Import path escapes source.'); + } + await _safeAbsolute(path); + final resolved = await File(path).resolveSymbolicLinks(); + if (!p.isWithin(root, resolved)) { + throw const FileSystemException('Import path escapes source.'); + } + } + + static Future _safeAbsolute(String path) async { + var current = p.normalize(p.absolute(path)); + while (true) { + if (await FileSystemEntity.isLink(current)) { + throw const FileSystemException( + 'Symbolic links are not allowed in transfers.', + ); + } + final parent = p.dirname(current); + if (parent == current) break; + current = parent; + } + } + + static String _uniqueName( + String input, + Set used, + String scope, { + String extension = '', + }) { + var stem = input + .replaceAll(RegExp(r'[<>:"/\\|?*\x00-\x1f\x7f]'), '_') + .trim() + .replaceAll(RegExp(r'[. ]+$'), ''); + if (stem.isEmpty || + RegExp( + r'^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(?:\..*)?$', + caseSensitive: false, + ).hasMatch(stem)) { + stem = 'note'; + } + // Bound UTF-8 component bytes, leaving room for collision suffixes. + while (utf8.encode(stem).length > 120) { + stem = String.fromCharCodes(stem.runes.take(stem.runes.length - 1)); + } + var name = '$stem$extension'; + var suffix = 1; + while (!used.add('$scope:${normalizeNotesSearch(name)}')) { + name = '$stem-${++suffix}$extension'; + } + return name; + } +} diff --git a/lib/src/nextcloud_notes/data/notes_api_client.dart b/lib/src/nextcloud_notes/data/notes_api_client.dart index ea5ec85a..7f83d4c2 100644 --- a/lib/src/nextcloud_notes/data/notes_api_client.dart +++ b/lib/src/nextcloud_notes/data/notes_api_client.dart @@ -7,6 +7,7 @@ import 'package:http/http.dart' as http; import '../../assets/asset_limits.dart'; import '../domain/notes_models.dart'; +import '../domain/notes_retrieval.dart'; import 'notes_attachment_references.dart'; import 'server_uri.dart'; @@ -51,7 +52,10 @@ class NotesApiClient { required this.account, required String appPassword, this.timeout = const Duration(seconds: 30), - }) : _client = client, + this.onApiVersions, + DateTime Function()? clock, + }) : clock = clock ?? DateTime.now, + _client = client, _authorization = 'Basic ${base64Encode(utf8.encode('${account.loginName}:$appPassword'))}'; @@ -59,6 +63,67 @@ class NotesApiClient { final NextcloudAccount account; final String _authorization; final Duration timeout; + final DateTime Function() clock; + final Future Function(String)? onApiVersions; + + /// Web navigation configuration, observed without sending any credential. + /// This is not a Notes synchronization or collection retrieval request. + Future noteWebPrettyUrls() async { + try { + final request = http.Request( + 'GET', + nextcloudEndpoint(account.server, 'index.php/login'), + )..followRedirects = false; + final response = await _client.send(request).timeout(timeout); + if (response.statusCode != 200) { + throw const FormatException('Web route unavailable.'); + } + final bytes = []; + final input = StreamIterator(response.stream); + final bodyDeadline = Stopwatch()..start(); + try { + while (await input.moveNext().timeout(timeout - bodyDeadline.elapsed)) { + bytes.addAll(input.current); + if (bytes.length > 2 * 1024 * 1024) { + throw const FormatException('Web configuration too large.'); + } + } + } finally { + await input.cancel(); + } + final source = utf8.decode(bytes); + final config = RegExp( + r'var\s+_oc_config\s*=\s*(\{[^\n]*\})\s*;', + ).firstMatch(source); + final value = config == null + ? null + : jsonDecode(config[1]!)['modRewriteWorking']; + if (value is! bool) throw const FormatException('Web route unconfirmed.'); + return value; + } on Object { + throw const NotesException( + NotesFailureCode.network, + 'The installation’s note web route could not be verified. Manual URLs remain available.', + scope: NotesRequestScope.account, + ); + } + } + + NotesRequestScope _scope(http.BaseRequest request) => + request.url.path.contains('/attachment/') + ? NotesRequestScope.attachment + : request.url.path.endsWith('/settings') + ? NotesRequestScope.settings + : request.url.path.endsWith('/notes') + ? NotesRequestScope.collection + : NotesRequestScope.note; + Future _observe(Map headers) async { + final value = headers.entries + .where((e) => e.key.toLowerCase() == 'x-notes-api-versions') + .firstOrNull + ?.value; + if (value != null) await onApiVersions?.call(value); + } Uri _endpoint(String path, [Map? query]) => nextcloudEndpoint( account.server, @@ -74,6 +139,7 @@ class NotesApiClient { final response = await http.Response.fromStream( await _client.send(request).timeout(timeout), ).timeout(timeout); + await _observe(response.headers); if (response.statusCode >= 300 && response.statusCode < 400 && response.statusCode != 304) { @@ -83,22 +149,25 @@ class NotesApiClient { ); } return response; - } on NotesException { - rethrow; + } on NotesException catch (error) { + throw error.inScope(_scope(request)); } on TimeoutException { - throw const NotesException( + throw NotesException( NotesFailureCode.network, 'The Nextcloud request timed out. Local changes are preserved.', + scope: _scope(request), ); } on http.ClientException { - throw const NotesException( + throw NotesException( NotesFailureCode.network, 'Nextcloud is unreachable. Local changes are preserved.', + scope: _scope(request), ); } on IOException { - throw const NotesException( + throw NotesException( NotesFailureCode.network, 'Nextcloud is unreachable. Local changes are preserved.', + scope: _scope(request), ); } } @@ -111,13 +180,17 @@ class NotesApiClient { return null; } - static dynamic _json(http.Response response) { + static dynamic _json( + http.Response response, { + NotesRequestScope scope = NotesRequestScope.note, + }) { try { return jsonDecode(utf8.decode(response.bodyBytes)); } on FormatException { - throw const NotesException( + throw NotesException( NotesFailureCode.invalidResponse, 'Nextcloud returned invalid JSON.', + scope: scope, ); } } @@ -133,7 +206,10 @@ class NotesApiClient { return NoteState.fromJson(json); } - static void _check(http.Response response) { + void _check( + http.Response response, { + NotesRequestScope scope = NotesRequestScope.note, + }) { final status = response.statusCode; if (status >= 200 && status < 300) return; final (code, message) = switch (status) { @@ -149,6 +225,10 @@ class NotesApiClient { NotesFailureCode.missing, 'The note or attachment no longer exists on Nextcloud.', ), + 429 => ( + NotesFailureCode.throttled, + 'Nextcloud requested a pause before retrying. Local changes are preserved.', + ), 412 => ( NotesFailureCode.conflict, 'This note changed on Nextcloud. Resolve the conflict before synchronizing.', @@ -166,7 +246,7 @@ class NotesApiClient { 'Nextcloud returned server error $status. Local changes are preserved.', ), _ => ( - NotesFailureCode.server, + NotesFailureCode.rejected, 'Nextcloud rejected this request (HTTP $status). Local changes are preserved.', ), }; @@ -178,7 +258,14 @@ class NotesApiClient { /* Fetch fresh state separately. */ } } - throw NotesException(code, message, statusCode: status, remote: remote); + throw NotesException( + code, + message, + statusCode: status, + remote: remote, + scope: scope, + retryNotBefore: retryAfter(header(response, 'Retry-After'), clock()), + ); } /// Collects every chunk before returning; interrupted lists never imply deletion. @@ -198,7 +285,7 @@ class NotesApiClient { try { query['pruneBefore'] = '${HttpDate.parse(account.lastModified!).millisecondsSinceEpoch ~/ 1000}'; - } on FormatException { + } on HttpException { // Invalid persisted checkpoint safely triggers an unpruned list. } } @@ -211,12 +298,13 @@ class NotesApiClient { if (response.statusCode == 304 && cursor == null) { return const NotesListResult(notes: [], ids: {}, notModified: true); } - _check(response); - final json = _json(response); + _check(response, scope: NotesRequestScope.collection); + final json = _json(response, scope: NotesRequestScope.collection); if (json is! List) { throw const NotesException( NotesFailureCode.invalidResponse, 'Nextcloud returned an invalid notes list.', + scope: NotesRequestScope.collection, ); } for (final item in json) { @@ -226,11 +314,17 @@ class NotesApiClient { throw const NotesException( NotesFailureCode.invalidResponse, 'Nextcloud returned an invalid note identifier.', + scope: NotesRequestScope.collection, ); } ids.add(item['id'] as int); if (item.containsKey('etag') || item.containsKey('content')) { - final note = NoteState.fromJson(item); + final NoteState note; + try { + note = NoteState.fromJson(item); + } on NotesException catch (error) { + throw error.inScope(NotesRequestScope.collection); + } notes[note.id] = note; } } @@ -249,21 +343,156 @@ class NotesApiClient { throw const NotesException( NotesFailureCode.invalidResponse, 'Nextcloud repeated a notes chunk cursor.', + scope: NotesRequestScope.collection, ); } } throw const NotesException( NotesFailureCode.invalidResponse, 'Nextcloud returned too many notes chunks.', + scope: NotesRequestScope.collection, ); } Future get(int id) async { + final result = await retrieve(id); + return (result as NotesReturned).note; + } + + Future retrieve(int id, {NoteState? cached}) async { + if (id <= 0 || (cached != null && cached.id != id)) { + throw ArgumentError( + 'The cached representation must belong to this note.', + ); + } + final request = http.Request('GET', _endpoint('v1/notes/$id')); + if (cached != null && !cached.error) { + request.headers['If-None-Match'] = '"${cached.etag}"'; + } + final response = await _send(request); + if (response.statusCode == 304) { + if (cached == null || cached.error) { + throw const NotesException( + NotesFailureCode.invalidResponse, + 'Nextcloud returned 304 without a cached server representation.', + ); + } + return NotesNotModified(cached); + } + _check(response); + final note = _note(response); + if (note.id != id) { + throw const NotesException( + NotesFailureCode.invalidResponse, + 'Nextcloud returned a different note.', + ); + } + return NotesReturned(note); + } + + Future getProjection( + int id, + NotesRetrievalScope scope, + ) async { + if (id <= 0 || scope.category != null) { + throw ArgumentError('Invalid note query.'); + } final response = await _send( - http.Request('GET', _endpoint('v1/notes/$id')), + http.Request('GET', _endpoint('v1/notes/$id', scope.query)), ); _check(response); - return _note(response); + final value = _json(response); + if (value is! Map) { + throw const NotesException( + NotesFailureCode.invalidResponse, + 'Invalid projected note.', + ); + } + final note = NotesProjection.fromJson(value); + if (note.id != null && note.id != id || + note.id == null && !scope.exclude.contains('id')) { + throw const NotesException( + NotesFailureCode.invalidResponse, + 'Nextcloud returned a different note.', + ); + } + return note; + } + + Future query( + NotesRetrievalScope scope, { + int chunkSize = 100, + }) async { + if (chunkSize <= 0) throw ArgumentError.value(chunkSize); + final notes = {}; + final unidentified = []; + final cursors = {}; + String? cursor; + for (var chunk = 0; chunk < 10000; chunk++) { + final response = await _send( + http.Request( + 'GET', + _endpoint('v1/notes', { + ...scope.query, + 'chunkSize': '$chunkSize', + if (cursor != null) 'chunkCursor': cursor, + }), + ), + ); + _check(response, scope: NotesRequestScope.collection); + final value = _json(response, scope: NotesRequestScope.collection); + if (value is! List) { + throw const NotesException( + NotesFailureCode.invalidResponse, + 'Invalid projected notes list.', + scope: NotesRequestScope.collection, + ); + } + for (final item in value) { + if (item is! Map) { + throw const NotesException( + NotesFailureCode.invalidResponse, + 'Invalid projected note.', + scope: NotesRequestScope.collection, + ); + } + final NotesProjection note; + try { + note = NotesProjection.fromJson(item); + } on NotesException catch (error) { + throw error.inScope(NotesRequestScope.collection); + } + if (note.id == null) { + if (!scope.exclude.contains('id')) { + throw const NotesException( + NotesFailureCode.invalidResponse, + 'Missing projected note identifier.', + scope: NotesRequestScope.collection, + ); + } + unidentified.add(note); + continue; + } + if (scope.exclude.contains('id') && note.values.length == 1) continue; + // The final chunk repeats earlier IDs without their fields. + if (!notes.containsKey(note.id) || note.values.length > 1) { + notes[note.id!] = note; + } + } + cursor = header(response, 'X-Notes-Chunk-Cursor'); + if (cursor == null || cursor.isEmpty) { + return NotesQueryResult( + scope: scope, + notes: [...notes.values, ...unidentified], + ); + } + if (!cursors.add(cursor)) break; + } + throw const NotesException( + NotesFailureCode.invalidResponse, + 'Invalid projected notes continuation.', + scope: NotesRequestScope.collection, + ); } static Map writable(NextcloudNote note) => { @@ -271,6 +500,10 @@ class NotesApiClient { 'title': note.title, 'category': note.category, 'favorite': note.favorite, + if (note.localActivityMicros != null || note.modified > 0) + 'modified': note.localActivityMicros == null + ? note.modified + : note.localActivityMicros! ~/ 1000000, }; static Future> creationAttributes( @@ -286,7 +519,6 @@ class NotesApiClient { if (reference.reference.startsWith('busymark-attachment:')) reference.reference: '', }); - attributes['modified'] = DateTime.now().millisecondsSinceEpoch ~/ 1000; return attributes; } @@ -297,7 +529,7 @@ class NotesApiClient { note.creationAttempt?.wireBody ?? jsonEncode(await creationAttributes(note)); final response = await _send(request); - _check(response); + _check(response, scope: NotesRequestScope.collection); return _note(response); } @@ -348,8 +580,8 @@ class NotesApiClient { http.MultipartFile.fromBytes('file', bytes, filename: filename), ); final response = await _send(request); - _check(response); - final json = _json(response); + _check(response, scope: NotesRequestScope.attachment); + final json = _json(response, scope: NotesRequestScope.attachment); if (json is! Map || json['filename'] is! String || !isSafeAttachmentPath(json['filename'] as String) || @@ -357,6 +589,7 @@ class NotesApiClient { throw const NotesException( NotesFailureCode.invalidResponse, 'Nextcloud returned an unsafe attachment filename.', + scope: NotesRequestScope.attachment, ); } return json['filename'] as String; @@ -375,9 +608,8 @@ class NotesApiClient { 'This attachment reference is unsafe.', ); } - final decodedPath = Uri.decodeComponent(path); - if (decodedPath.startsWith('.attachments.') && - !decodedPath.startsWith('.attachments.$id/')) { + if (path.startsWith('.attachments.') && + !path.startsWith('.attachments.$id/')) { throw const NotesException( NotesFailureCode.unsafeReference, 'This attachment belongs to a different Nextcloud note.', @@ -402,13 +634,17 @@ class NotesApiClient { // Register the stream before checking status/headers so every exit cancels it. input = StreamIterator(response.stream.timeout(timeout)); cancel.check(); + await _observe(response.headers); if (response.statusCode >= 300 && response.statusCode < 400) { throw const NotesException( NotesFailureCode.authentication, 'Nextcloud redirected an authenticated attachment request. Reconnect using its canonical HTTPS URL.', ); } - _check(http.Response('', response.statusCode)); + _check( + http.Response('', response.statusCode, headers: response.headers), + scope: NotesRequestScope.attachment, + ); if (response.statusCode != 200) { throw const NotesException( NotesFailureCode.invalidResponse, @@ -491,23 +727,26 @@ class NotesApiClient { } cancel.check(); return await partial.rename(destination.path); - } on NotesException { - rethrow; + } on NotesException catch (error) { + throw error.inScope(NotesRequestScope.attachment); } on TimeoutException { throw const NotesException( NotesFailureCode.network, 'The attachment download timed out.', + scope: NotesRequestScope.attachment, ); } on http.ClientException { cancel.check(); throw const NotesException( NotesFailureCode.network, 'The attachment download was interrupted.', + scope: NotesRequestScope.attachment, ); } on IOException { throw const NotesException( NotesFailureCode.network, 'The attachment could not be downloaded safely.', + scope: NotesRequestScope.attachment, ); } finally { if (!abort.isCompleted) abort.complete(); @@ -536,61 +775,71 @@ class NotesApiClient { ..headers['Content-Type'] = 'application/json' ..body = jsonEncode({'path': path}); final response = await _send(request); - _check(response); + _check(response, scope: NotesRequestScope.attachment); } -} -bool isSafeAttachmentReference(String reference) { - if (reference.isEmpty || - reference.contains('\\') || - RegExp(r'[\x00-\x1f\x7f]').hasMatch(reference)) { - return false; - } - final uri = Uri.tryParse(reference); - if (uri == null || - uri.hasScheme || - uri.hasAuthority || - uri.hasQuery || - uri.hasFragment || - reference.startsWith('/')) { - return false; + Future getSettings() async { + final response = await _send(http.Request('GET', _endpoint('v1/settings'))); + _check(response, scope: NotesRequestScope.settings); + return NotesSettings.fromJson( + _json(response, scope: NotesRequestScope.settings), + ); } - try { - final decoded = Uri.decodeComponent(reference); - if (decoded.startsWith('/') || decoded.contains('\\')) return false; - return decoded - .split('/') - .every((part) => part.isNotEmpty && part != '.' && part != '..'); - } on FormatException { - return false; + + Future updateSettings(Map patch) async { + if (patch.keys.any((key) => key != 'notesPath' && key != 'fileSuffix')) { + throw ArgumentError('Unknown Notes setting.'); + } + final request = http.Request('PUT', _endpoint('v1/settings')) + ..headers['Content-Type'] = 'application/json' + ..body = jsonEncode(patch); + final response = await _send(request); + _check(response, scope: NotesRequestScope.settings); + return NotesSettings.fromJson( + _json(response, scope: NotesRequestScope.settings), + ); } } -/// API filenames are opaque paths: a literal # can be a valid filename. The -/// Markdown URI spelling is decoded separately before passing it to the API. +/// Decode Markdown destinations once; API paths always remain raw. +bool isSafeAttachmentReference(String reference) => + canonicalAttachmentReference(reference) != null; + bool isSafeAttachmentPath(String path) { if (path.isEmpty || path.startsWith('/') || path.contains('\\') || - RegExp(r'[\x00-\x1f\x7f]').hasMatch(path) || - Uri.tryParse(path)?.hasScheme != false) { - return false; - } - try { - final decoded = Uri.decodeComponent(path); - if (decoded.startsWith('/') || decoded.contains('\\')) return false; - return decoded - .split('/') - .every((part) => part.isNotEmpty && part != '.' && part != '..'); - } on FormatException { + RegExp(r'[\x00-\x1f\x7f]').hasMatch(path)) { return false; } + // A colon in the first segment could be a URI scheme. Percent characters are opaque. + if (path.split('/').first.contains(':')) return false; + return path + .split('/') + .every((part) => part.isNotEmpty && part != '.' && part != '..'); } String? canonicalAttachmentReference(String reference) { - if (!isSafeAttachmentReference(reference)) return null; + final uri = Uri.tryParse(reference); + if (uri == null || + uri.hasScheme || + uri.hasAuthority || + uri.hasQuery || + uri.hasFragment || + reference.contains('\\')) { + return null; + } + // Uri.parse tolerates stray percent signs; a destination must be valid encoding. + if (RegExp(r'%(?![0-9a-fA-F]{2})').hasMatch(reference)) return null; try { - return Uri.decodeComponent(reference); + // Escape literal Unicode before decoding exactly once. Do not use uri.path: + // Uri.parse normalizes dot segments before traversal can be validated. + final encoded = reference.replaceAllMapped( + RegExp(r'[^\x00-\x7f]+'), + (match) => Uri.encodeComponent(match[0]!), + ); + final raw = Uri.decodeComponent(encoded); + return isSafeAttachmentPath(raw) ? raw : null; } on FormatException { return null; } @@ -605,9 +854,28 @@ String attachmentMarkdownReference(String path) => path ) .join('/'); -/// API 1.4 returns flat filenames on 6.0.x and scoped filenames on 6.1.x. -/// A foreign note folder or any other directory is never an upload destination. bool isNoteAttachmentPath(int id, String path) => isSafeAttachmentPath(path) && - (!Uri.decodeComponent(path).contains('/') || - Uri.decodeComponent(path).startsWith('.attachments.$id/')); + (!path.contains('/') || + (path.startsWith('.attachments.$id/') && path.split('/').length == 2)); + +/// RFC 9110: retain a server deadline, including long delays; never clamp earlier. +DateTime? retryAfter(String? value, DateTime now) { + if (value == null) return null; + final text = value.trim(); + if (RegExp(r'^\d+$').hasMatch(text)) { + final seconds = int.tryParse(text); + if (seconds == null || seconds > 8640000000000) { + return DateTime.utc(275760, 1, 1); + } + final maximum = DateTime.utc(275760, 1, 1); + if (seconds > maximum.difference(now.toUtc()).inSeconds) return maximum; + return now.add(Duration(seconds: seconds)); + } + try { + final deadline = HttpDate.parse(text); + return deadline.isAfter(now) ? deadline : now; + } on HttpException { + return null; + } +} diff --git a/lib/src/nextcloud_notes/data/notes_capabilities.dart b/lib/src/nextcloud_notes/data/notes_capabilities.dart index d441b750..29aae81b 100644 --- a/lib/src/nextcloud_notes/data/notes_capabilities.dart +++ b/lib/src/nextcloud_notes/data/notes_capabilities.dart @@ -1,20 +1,33 @@ +import 'dart:async'; import 'dart:convert'; +import 'dart:io'; import 'package:http/http.dart' as http; import 'server_uri.dart'; +import 'notes_api_client.dart' show retryAfter; enum NextcloudCapabilityFailure { unsupported, unauthorized, malformed, network, + rejected, + forbidden, + throttled, } class NextcloudCapabilityException implements Exception { - const NextcloudCapabilityException(this.code, this.message); + const NextcloudCapabilityException( + this.code, + this.message, { + this.statusCode, + this.retryNotBefore, + }); final NextcloudCapabilityFailure code; final String message; + final int? statusCode; + final DateTime? retryNotBefore; @override String toString() => message; } @@ -49,29 +62,44 @@ class NotesCapabilities { } final version = notes['version']; final versions = notes['api_version']; - final minors = []; - if (versions is List) { - for (final advertised in versions) { - if (advertised is! String) continue; - final match = RegExp(r'^1\.(\d+)$').firstMatch(advertised); - final minor = match == null ? null : int.tryParse(match.group(1)!); - if (minor != null && minor >= 4) minors.add(minor); - } + if (versions is! List || + (versions.isNotEmpty && + !versions.any( + (v) => v is String && RegExp(r'^\d+\.\d+$').hasMatch(v.trim()), + ))) { + throw const NextcloudCapabilityException( + NextcloudCapabilityFailure.malformed, + 'The Nextcloud server returned invalid Notes API versions.', + ); } - if (minors.isEmpty) { + final supported = parseNotesApiVersions(versions); + if (supported == null) { throw const NextcloudCapabilityException( NextcloudCapabilityFailure.unsupported, 'This Nextcloud Notes API is unsupported. BusyMark requires Notes API major version 1 with minor version 4 or later.', ); } - minors.sort(); return NotesCapabilities( appVersion: version is String ? version : '', - apiVersion: '1.${minors.last}', + apiVersion: supported, ); } } +/// Shared OCS/header boundary. Application versions are separate evidence. +String? parseNotesApiVersions(Iterable versions) { + final minors = []; + for (final version in versions) { + if (version is! String) continue; + final match = RegExp(r'^1\.(\d+)$').firstMatch(version.trim()); + final minor = match == null ? null : int.tryParse(match.group(1)!); + if (minor != null && minor >= 4) minors.add(minor); + } + if (minors.isEmpty) return null; + minors.sort(); + return '1.${minors.last}'; +} + String nextcloudAuthorization(String loginName, String appPassword) => 'Basic ${base64Encode(utf8.encode('$loginName:$appPassword'))}'; @@ -102,7 +130,17 @@ Future fetchNotesCapabilities({ response = await http.Response.fromStream( await client.send(request).timeout(const Duration(seconds: 30)), ).timeout(const Duration(seconds: 30)); - } on Object { + } on TimeoutException { + throw const NextcloudCapabilityException( + NextcloudCapabilityFailure.network, + 'The Nextcloud capabilities request timed out.', + ); + } on IOException { + throw const NextcloudCapabilityException( + NextcloudCapabilityFailure.network, + 'The Nextcloud capabilities endpoint is unreachable.', + ); + } on http.ClientException { throw const NextcloudCapabilityException( NextcloudCapabilityFailure.network, 'BusyMark could not verify the Nextcloud Notes capabilities. Check your connection and try again.', @@ -120,6 +158,32 @@ Future fetchNotesCapabilities({ 'The Nextcloud capabilities endpoint redirected. Reconnect using the canonical server URL.', ); } + if (response.statusCode == 429) { + final value = response.headers.entries + .where((e) => e.key.toLowerCase() == 'retry-after') + .firstOrNull + ?.value; + throw NextcloudCapabilityException( + NextcloudCapabilityFailure.throttled, + 'Nextcloud requested a pause before checking capabilities.', + statusCode: 429, + retryNotBefore: retryAfter(value, DateTime.now()), + ); + } + if (response.statusCode == 403) { + throw const NextcloudCapabilityException( + NextcloudCapabilityFailure.forbidden, + 'Nextcloud does not permit capability checks.', + statusCode: 403, + ); + } + if (response.statusCode >= 400 && response.statusCode < 500) { + throw NextcloudCapabilityException( + NextcloudCapabilityFailure.rejected, + 'The capability endpoint rejected the request (HTTP ${response.statusCode}).', + statusCode: response.statusCode, + ); + } if (response.statusCode != 200) { throw const NextcloudCapabilityException( NextcloudCapabilityFailure.network, @@ -127,12 +191,12 @@ Future fetchNotesCapabilities({ ); } try { - return NotesCapabilities.fromOcsJson( - jsonDecode(response.body) as Map, - ); + final json = jsonDecode(response.body); + if (json is! Map) throw const FormatException(); + return NotesCapabilities.fromOcsJson(json); } on NextcloudCapabilityException { rethrow; - } on Object { + } on FormatException { throw const NextcloudCapabilityException( NextcloudCapabilityFailure.malformed, 'The Nextcloud server returned invalid capabilities.', diff --git a/lib/src/nextcloud_notes/data/notes_store.dart b/lib/src/nextcloud_notes/data/notes_store.dart index 93ddb9b8..0161959f 100644 --- a/lib/src/nextcloud_notes/data/notes_store.dart +++ b/lib/src/nextcloud_notes/data/notes_store.dart @@ -9,6 +9,7 @@ import 'package:sqlite3/sqlite3.dart'; import '../../assets/asset_limits.dart'; import '../domain/notes_models.dart'; +import '../domain/notes_search.dart'; /// Focused durable Notes store. All synchronous SQLite work runs in one isolate. class NotesStore { @@ -152,6 +153,7 @@ class NotesStore { NextcloudNote note, List<({NotesAttachment attachment, Uint8List bytes})> attachments, { List additionalNotes = const [], + String? importKey, }) async { await _call('createWithAttachments', [ note.toJson(), @@ -166,6 +168,7 @@ class NotesStore { ) .toList(), additionalNotes.map((n) => n.toJson()).toList(), + importKey, ]); } @@ -198,6 +201,9 @@ class NotesStore { ) .toList(); + Future attachmentSize(String id) async => + await _call('attachmentSize', id) as int?; + Future attachmentBytes(String id) async => await _call('attachmentBytes', id) as Uint8List?; @@ -216,6 +222,80 @@ class NotesStore { await _call('removeAttachment', id); } + /// Each step is bounded so queued durable saves can run between steps. + Future indexStep() async => await _call('indexStep') as int; + Future rebuildIndex() async => await _call('rebuildIndex'); + Future> searchChunk( + String accountId, + NotesSearchQuery query, { + int after = 0, + Map? continuation, + bool recovery = false, + int limit = 80, + Set exclude = const {}, + }) async => Map.from( + await _call('searchChunk', { + 'account': accountId, + 'query': query.text, + 'wholeWord': query.wholeWord, + 'after': after, + 'continuation': continuation, + 'recovery': recovery, + 'limit': limit.clamp(1, 200), + 'exclude': exclude.toList(), + }) + as Map, + ); + Future>> offlineRequirements( + String accountId, + ) async => (await _call('offlineRequirements', accountId) as List) + .map((v) => Map.from(v as Map)) + .toList(); + Future setOfflineRequirement( + String accountId, + String kind, + String target, { + bool required = true, + bool paused = false, + }) async => await _call('setOfflineRequirement', [ + accountId, + kind, + target, + required, + paused, + ]); + Future beginImportOperation( + String accountId, + String operationId, + String root, + String review, + ) async => await _call('beginImportOperation', [ + accountId, + operationId, + root, + review, + ]); + Future>> importOperations(String accountId) async => + (await _call('importOperations', accountId) as List) + .map((r) => Map.from(r as Map)) + .toList(); + Future?> importOperation( + String accountId, + String operationId, + ) async { + final row = await _call('importOperation', [accountId, operationId]); + return row == null ? null : Map.from(row as Map); + } + + Future completeImportOperation( + String accountId, + String operationId, + List keys, + ) async => + await _call('completeImportOperation', [accountId, operationId, keys]); + Future importedNote(String accountId, String sourceKey) async => + await _call('importedNote', [accountId, sourceKey]) as String?; + Future close() => _closing ??= _close(); Future _close() async { @@ -244,7 +324,7 @@ void _worker(List arguments) { db.execute('PRAGMA synchronous = FULL'); db.execute('PRAGMA busy_timeout = 5000'); final version = db.select('PRAGMA user_version').first.values.first as int; - if (version > 2) { + if (version > 6) { throw StateError( 'The Notes database was created by a newer BusyMark version.', ); @@ -274,10 +354,14 @@ void _worker(List arguments) { // v2 stores durable creation attempts in the existing versioned note JSON. // Older uncertain operations have no attempt and remain explicitly unresolved. // Prevent old clients from opening and losing this new safety metadata. - if (version < 2) db.execute('PRAGMA user_version = 2'); + // v3 fences durable retry/error provenance, edit times and settings attempts. + // v4 fences competing local metadata evidence from older clients. + // Advance only inside the recovery transaction; malformed rows roll back. + // A request interrupted by a crash has an uncertain creation/upload outcome. db.execute('BEGIN IMMEDIATE'); try { + _createWorkspaceSchema(db); for (final row in db.select('SELECT data FROM notes')) { final note = NextcloudNote.fromJson( jsonDecode(row['data'] as String) as Map, @@ -293,6 +377,7 @@ void _worker(List arguments) { ); } } + if (version < 6) db.execute('PRAGMA user_version = 6'); db.execute('COMMIT'); } catch (_) { db.execute('ROLLBACK'); @@ -326,6 +411,7 @@ void _writeNote(Database db, NextcloudNote note) { 'INSERT INTO notes(id,account_id,server_id,data) VALUES(?,?,?,?) ON CONFLICT(id) DO UPDATE SET server_id=excluded.server_id,data=excluded.data', [note.localId, note.accountId, note.serverId, note.encode()], ); + _indexNote(db, note); if (note.hasPendingChanges) { db.execute( 'INSERT INTO outbox(note_id,revision,operation,data) VALUES(?,?,?,?) ON CONFLICT(note_id) DO UPDATE SET revision=excluded.revision,operation=excluded.operation,data=excluded.data', @@ -343,6 +429,201 @@ void _writeNote(Database db, NextcloudNote note) { Object? _execute(Database db, String action, dynamic argument) { switch (action) { + case 'indexStep': + final rows = db.select( + "SELECT n.data FROM notes n LEFT JOIN note_search_map s ON s.id=n.id WHERE s.id IS NULL OR s.revision!=json_extract(n.data,'\$.revision') LIMIT 32", + ); + db.execute('BEGIN IMMEDIATE'); + try { + for (final row in rows) { + _indexNote( + db, + NextcloudNote.fromJson( + jsonDecode(row['data'] as String) as Map, + ), + ); + } + db.execute('COMMIT'); + } catch (_) { + db.execute('ROLLBACK'); + rethrow; + } + return rows.length; + case 'rebuildIndex': + db.execute('BEGIN IMMEDIATE'); + try { + db.execute('DELETE FROM note_search'); + db.execute('DELETE FROM note_search_map'); + db.execute('COMMIT'); + } catch (_) { + db.execute('ROLLBACK'); + rethrow; + } + return null; + case 'searchChunk': + final a = argument as Map; + final query = NotesSearchQuery( + a['query'] as String, + wholeWord: a['wholeWord'] as bool, + ); + final expression = query.ftsCandidates; + final recovery = a['recovery'] == true; + final rows = db.select( + recovery + ? 'SELECT rowid AS cursor,data FROM notes WHERE account_id=? AND rowid>? ORDER BY rowid LIMIT 32' + : 'SELECT note_search.rowid AS cursor,n.data FROM note_search JOIN notes n ON n.id=note_search.id WHERE note_search.account=? AND note_search.rowid>? ' + "${expression == null ? '' : 'AND note_search MATCH ? '}ORDER BY note_search.rowid LIMIT 32", + [ + a['account'], + a['after'], + if (!recovery && expression != null) expression, + ], + ); + final hits = >[]; + final exclude = (a['exclude'] as List).cast().toSet(); + final limit = a['limit'] as int; + var after = a['after'] as int; + Map? continuation = a['continuation'] as Map?; + for (var index = 0; index < rows.length; index++) { + final row = rows[index]; + final note = NextcloudNote.fromJson( + jsonDecode(row['data'] as String) as Map, + ); + if (continuation != null && + (continuation['id'] != note.localId || + continuation['revision'] != note.revision || + continuation['title'] != note.title || + continuation['category'] != note.category || + continuation['digest'] != notesSearchDigest(note.content))) { + throw StateError('Search continuation changed; refresh the query.'); + } + final deleted = + note.syncState == NoteSyncState.deletedRemotely && + !note.hasPendingChanges; + if (!exclude.contains(note.localId) && deleted == recovery) { + final remaining = limit - hits.length; + final page = matchNotesDocument( + query: query, + localId: note.localId, + revision: note.revision, + title: note.title, + category: note.category, + source: note.content, + limit: remaining + 1, + afterStart: continuation?['start'] as int? ?? -1, + afterEnd: continuation?['end'] as int? ?? -1, + ); + hits.addAll(page.take(remaining).map((h) => h.toJson())); + if (page.length > remaining) { + final last = page[remaining - 1]; + return { + 'hits': hits, + 'after': after, + 'more': true, + 'continuation': { + 'id': note.localId, + 'revision': note.revision, + 'title': note.title, + 'category': note.category, + 'digest': last.digest, + 'start': last.start, + 'end': last.end, + }, + }; + } + } + // Advance only after the complete document has been consumed. + after = row['cursor'] as int; + continuation = null; + if (hits.length == limit) { + return { + 'hits': hits, + 'after': after, + 'continuation': null, + 'more': index < rows.length - 1 || rows.length == 32, + }; + } + } + if (continuation != null) throw StateError('Search continuation lost.'); + return { + 'hits': hits, + 'after': after, + 'continuation': null, + 'more': rows.length == 32, + }; + case 'beginImportOperation': + final a = argument as List; + db.execute( + "INSERT INTO import_operations(account_id,id,root,review,state) VALUES(?,?,?,?,'pending') ON CONFLICT(account_id,id) DO UPDATE SET review=excluded.review WHERE import_operations.state='pending' AND import_operations.root=excluded.root", + a, + ); + return null; + case 'importOperations': + return db + .select( + "SELECT id,root,state,created_at FROM import_operations WHERE account_id=? AND state='pending' ORDER BY rowid DESC", + [argument], + ) + .map((r) => Map.from(r)) + .toList(); + case 'importOperation': + final a = argument as List; + final rows = db.select( + 'SELECT id,root,review,state FROM import_operations WHERE account_id=? AND id=?', + a, + ); + return rows.isEmpty ? null : Map.from(rows.single); + case 'completeImportOperation': + final a = argument as List; + db.execute('BEGIN IMMEDIATE'); + try { + for (final key in a[2] as List) { + if (db.select( + 'SELECT note_id FROM import_items WHERE account_id=? AND source_key=?', + [a[0], key], + ).isEmpty) { + throw StateError('An import item is not durable.'); + } + } + db.execute( + "UPDATE import_operations SET state='completed' WHERE account_id=? AND id=?", + [a[0], a[1]], + ); + db.execute('COMMIT'); + } catch (_) { + db.execute('ROLLBACK'); + rethrow; + } + return null; + case 'offlineRequirements': + return db + .select( + 'SELECT kind,target,paused FROM offline_requirements WHERE account_id=? ORDER BY kind,target', + [argument], + ) + .map((r) => Map.from(r)) + .toList(); + case 'setOfflineRequirement': + final a = argument as List; + if (a[3] == true) { + db.execute( + 'INSERT INTO offline_requirements(account_id,kind,target,paused) VALUES(?,?,?,?) ON CONFLICT(account_id,kind,target) DO UPDATE SET paused=excluded.paused', + [a[0], a[1], a[2], a[4] == true ? 1 : 0], + ); + } else { + db.execute( + 'DELETE FROM offline_requirements WHERE account_id=? AND kind=? AND target=?', + [a[0], a[1], a[2]], + ); + } + return null; + case 'importedNote': + final a = argument as List; + final rows = db.select( + 'SELECT note_id FROM import_items WHERE account_id=? AND source_key=?', + [a[0], a[1]], + ); + return rows.isEmpty ? null : rows.single['note_id']; case 'accounts': return db .select('SELECT data FROM accounts ORDER BY id') @@ -395,7 +676,7 @@ Object? _execute(Database db, String action, dynamic argument) { throw StateError('Attachment too large.'); } db.execute( - 'INSERT INTO attachments(id,note_id,data,bytes) VALUES(?,?,?,?)', + 'INSERT INTO attachments(id,note_id,data,bytes) VALUES(?,?,?,?) ON CONFLICT(id) DO UPDATE SET data=excluded.data,bytes=excluded.bytes', [a[0], a[1], a[2], bytes], ); return null; @@ -442,6 +723,15 @@ Object? _execute(Database db, String action, dynamic argument) { (attachment as List).cast(), ); } + if (a.length > 3 && a[3] != null) { + final note = NextcloudNote.fromJson( + Map.from(a[0] as Map), + ); + db.execute( + 'INSERT INTO import_items(account_id,source_key,note_id) VALUES(?,?,?)', + [note.accountId, a[3], note.localId], + ); + } db.execute('COMMIT'); } catch (_) { db.execute('ROLLBACK'); @@ -472,6 +762,12 @@ Object? _execute(Database db, String action, dynamic argument) { rethrow; } return null; + case 'attachmentSize': + final rows = db.select( + 'SELECT length(bytes) AS size FROM attachments WHERE id=?', + [argument], + ); + return rows.isEmpty ? null : rows.single['size']; case 'attachmentBytes': final rows = db.select('SELECT bytes FROM attachments WHERE id=?', [ argument, @@ -504,3 +800,81 @@ Object? _execute(Database db, String action, dynamic argument) { throw StateError('Unknown Notes storage operation.'); } } + +void _createWorkspaceSchema(Database db) { + // A runtime FTS5/trigram capability check uses the same native library as + // durable notes. A lost derived index is recreated without touching outbox. + final hadIndex = db + .select("SELECT name FROM sqlite_master WHERE name='note_search'") + .isNotEmpty; + db.execute( + 'CREATE TABLE IF NOT EXISTS note_search_map(id TEXT PRIMARY KEY REFERENCES notes(id) ON DELETE CASCADE, docid INTEGER UNIQUE, revision INTEGER NOT NULL)', + ); + db.execute( + 'CREATE VIRTUAL TABLE IF NOT EXISTS note_search USING fts5(id UNINDEXED, account UNINDEXED, revision UNINDEXED, title, category, body, tokenize="trigram")', + ); + db.execute( + 'CREATE TRIGGER IF NOT EXISTS notes_search_delete BEFORE DELETE ON notes BEGIN DELETE FROM note_search WHERE rowid=(SELECT docid FROM note_search_map WHERE id=old.id); END', + ); + if (!hadIndex || + db.select('SELECT count(*) AS n FROM note_search').single['n'] != + db + .select('SELECT count(docid) AS n FROM note_search_map') + .single['n']) { + db.execute('DELETE FROM note_search'); + db.execute('DELETE FROM note_search_map'); + } + db.execute( + "CREATE TABLE IF NOT EXISTS offline_requirements(account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, kind TEXT NOT NULL CHECK(kind IN ('note','category','pause')), target TEXT NOT NULL, paused INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(account_id,kind,target))", + ); + db.execute( + 'CREATE TABLE IF NOT EXISTS import_items(account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, source_key TEXT NOT NULL, note_id TEXT NOT NULL REFERENCES notes(id), PRIMARY KEY(account_id,source_key))', + ); // v6 namespaces imports by a durable reviewed operation. Existing v5 item + // associations remain intact; they cannot suppress a deliberately new import. + db.execute( + "CREATE TABLE IF NOT EXISTS import_operations(account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, id TEXT NOT NULL, root TEXT NOT NULL, review TEXT NOT NULL, created_at INTEGER NOT NULL DEFAULT(unixepoch()), state TEXT NOT NULL CHECK(state IN ('pending','completed')), PRIMARY KEY(account_id,id))", + ); +} + +void _indexNote(Database db, NextcloudNote note) { + final existing = db.select( + 'SELECT s.rowid,s.revision,s.title,s.category,s.body FROM note_search s JOIN note_search_map m ON s.rowid=m.docid WHERE m.id=?', + [note.localId], + ); + if (note.syncState == NoteSyncState.deletedRemotely && + !note.hasPendingChanges) { + if (existing.isNotEmpty) { + db.execute('DELETE FROM note_search WHERE rowid=?', [ + existing.single['rowid'], + ]); + } + db.execute( + 'INSERT INTO note_search_map(id,docid,revision) VALUES(?,NULL,?) ON CONFLICT(id) DO UPDATE SET docid=NULL,revision=excluded.revision', + [note.localId, note.revision], + ); + return; + } + final title = normalizeNotesSearch(note.title); + final category = normalizeNotesSearch(note.category); + final body = normalizeNotesSearch(note.content); + if (existing.isNotEmpty && + existing.single['revision'] == note.revision && + existing.single['title'] == title && + existing.single['category'] == category && + existing.single['body'] == body) { + return; + } + final rowid = existing.isEmpty ? null : existing.single['rowid']; + if (rowid != null) { + db.execute('DELETE FROM note_search WHERE rowid=?', [rowid]); + } + db.execute( + 'INSERT INTO note_search(rowid,id,account,revision,title,category,body) VALUES(?,?,?,?,?,?,?)', + [rowid, note.localId, note.accountId, note.revision, title, category, body], + ); + final docid = rowid ?? db.lastInsertRowId; + db.execute( + 'INSERT INTO note_search_map(id,docid,revision) VALUES(?,?,?) ON CONFLICT(id) DO UPDATE SET docid=excluded.docid,revision=excluded.revision', + [note.localId, docid, note.revision], + ); +} diff --git a/lib/src/nextcloud_notes/data/server_uri.dart b/lib/src/nextcloud_notes/data/server_uri.dart index a7e12b42..e6ed8b0f 100644 --- a/lib/src/nextcloud_notes/data/server_uri.dart +++ b/lib/src/nextcloud_notes/data/server_uri.dart @@ -9,7 +9,12 @@ Uri normalizeNextcloudServer(String input) { // that normalization so a supplied installation prefix cannot move upwards. if (rawPath != null && rawPath.split('/').any((part) { - final decoded = Uri.decodeComponent(part); + final decoded = Uri.decodeComponent( + part.replaceAllMapped( + RegExp(r'[^\x00-\x7f]+'), + (m) => Uri.encodeComponent(m[0]!), + ), + ); return decoded == '.' || decoded == '..'; })) { throw const FormatException('Enter a valid HTTPS Nextcloud server URL.'); @@ -66,6 +71,47 @@ bool nextcloudSameOrigin(Uri first, Uri second) => first.host.toLowerCase() == second.host.toLowerCase() && first.port == second.port; +/// Portable web navigation, independent of title/category and local database IDs. +Uri nextcloudNoteUrl(Uri server, int serverId, {bool pretty = false}) { + if (serverId <= 0) throw ArgumentError.value(serverId, 'serverId'); + return nextcloudEndpoint( + server, + '${pretty ? '' : 'index.php/'}apps/notes/note/$serverId', + ); +} + +/// Recognizes only this exact installation, with front-controller or pretty URLs. +int? nextcloudNoteId(Uri server, String destination) { + try { + final base = normalizeNextcloudServer(server.toString()); + final uri = Uri.tryParse(destination); + if (uri == null || + !uri.hasAuthority || + !nextcloudSameOrigin(base, uri) || + uri.userInfo.isNotEmpty || + destination.contains(RegExp(r'[\x00-\x20\\]'))) { + return null; + } + // Match the encoded path exactly: encoded separators, dot normalization, + // misleading installation prefixes and route suffixes never alias a note. + final rawPath = RegExp( + r'^https://[^/?#]+([^?#]*)', + caseSensitive: false, + ).firstMatch(destination)?.group(1); + if (rawPath == null || rawPath != uri.path) return null; + for (final route in ['index.php/apps/notes/note/', 'apps/notes/note/']) { + final prefix = '${base.path}/$route'; + if (!rawPath.startsWith(prefix)) continue; + final suffix = rawPath.substring(prefix.length); + if (!RegExp(r'^[1-9][0-9]*$').hasMatch(suffix)) return null; + return int.tryParse(suffix); + } + } on FormatException { + return null; + } + return null; +} + /// Validates a flow URL before sending its token or opening the browser. Uri nextcloudFlowEndpoint(Uri server, Object? value) { if (value is! String) { diff --git a/lib/src/nextcloud_notes/domain/notes_conflict.dart b/lib/src/nextcloud_notes/domain/notes_conflict.dart index 3432aaf6..19bfc6a5 100644 --- a/lib/src/nextcloud_notes/domain/notes_conflict.dart +++ b/lib/src/nextcloud_notes/domain/notes_conflict.dart @@ -30,6 +30,28 @@ class NotesAttributeMerge { } class NotesConflictMerge { + NotesConflictMerge.metadata(NextcloudNote local) + : content = NotesAttributeMerge( + local.content, + local.content, + local.content, + ), + title = NotesAttributeMerge( + local.metadataConflict!.original.title, + local.title, + local.metadataConflict!.alternative.title, + ), + category = NotesAttributeMerge( + local.metadataConflict!.original.category, + local.category, + local.metadataConflict!.alternative.category, + ), + favorite = NotesAttributeMerge( + local.metadataConflict!.original.favorite, + local.favorite, + local.metadataConflict!.alternative.favorite, + ); + NotesConflictMerge(NextcloudNote local, NoteState remote) : content = NotesAttributeMerge( local.base?.content, diff --git a/lib/src/nextcloud_notes/domain/notes_models.dart b/lib/src/nextcloud_notes/domain/notes_models.dart index 3232e01d..3dc37ba4 100644 --- a/lib/src/nextcloud_notes/domain/notes_models.dart +++ b/lib/src/nextcloud_notes/domain/notes_models.dart @@ -1,5 +1,21 @@ +import 'dart:collection'; import 'dart:convert'; +/// Immutable document versions with fences for lazily copied attachment bytes. +/// A refresh or account removal invalidates publication, without holding the +/// durability queue while files are copied. +class NotesLocalSnapshot extends UnmodifiableListView { + NotesLocalSnapshot( + super.notes, { + required Map mediaVersions, + required Map accountGenerations, + }) : mediaVersions = Map.unmodifiable(mediaVersions), + accountGenerations = Map.unmodifiable(accountGenerations); + + final Map mediaVersions; + final Map accountGenerations; +} + enum NoteSyncState { synced, pending, @@ -13,6 +29,9 @@ enum NoteSyncState { unavailable, creationUncertain, deletedRemotely, + rejected, + throttled, + recoveryRequired, } enum NoteConflictResolution { @@ -35,15 +54,55 @@ enum NotesFailureCode { invalidResponse, unsupported, unsafeReference, + rejected, + throttled, +} + +enum NotesRequestScope { + account, + capability, + collection, + settings, + note, + attachment, } class NotesException implements Exception { - const NotesException(this.code, this.message, {this.statusCode, this.remote}); + const NotesException( + this.code, + this.message, { + this.statusCode, + this.remote, + this.scope = NotesRequestScope.note, + this.retryNotBefore, + }); final NotesFailureCode code; final String message; final int? statusCode; final NoteState? remote; + final NotesRequestScope scope; + final DateTime? retryNotBefore; + bool get retryable => { + NotesFailureCode.network, + NotesFailureCode.server, + NotesFailureCode.locked, + NotesFailureCode.throttled, + }.contains(code); + bool get possiblyExecuted => { + NotesFailureCode.network, + NotesFailureCode.server, + NotesFailureCode.invalidResponse, + NotesFailureCode.locked, + }.contains(code); + NotesException inScope(NotesRequestScope value) => NotesException( + code, + message, + statusCode: statusCode, + remote: remote, + scope: value, + retryNotBefore: retryNotBefore, + ); @override String toString() => message; @@ -58,6 +117,12 @@ class NextcloudAccount { this.apiVersion = '1.4', this.listEtag, this.lastModified, + this.lastServerCheck, + this.capabilitiesCheckedAt, + this.apiSupported = true, + this.settingsAttempt, + this.throttleNotBefore, + this.notesWebPrettyUrls, }); final String id; @@ -67,10 +132,19 @@ class NextcloudAccount { final String apiVersion; final String? listEtag; final String? lastModified; + final DateTime? lastServerCheck; + final DateTime? capabilitiesCheckedAt; + final bool apiSupported; + final NotesSettingsAttempt? settingsAttempt; + final DateTime? throttleNotBefore; + + /// Optional, backward-compatible observation of the installation's web router. + final bool? notesWebPrettyUrls; /// DELETE was added without an API capability bump (upstream #2037). /// Unknown/malformed/prerelease versions cannot establish support. bool get supportsAttachmentDeletion { + if (!apiSupported) return false; final match = RegExp( r'^(\d+)\.(\d+)\.(\d+)(?:\+[0-9A-Za-z.-]+)?$', ).firstMatch(appVersion); @@ -80,16 +154,44 @@ class NextcloudAccount { return parts[0]! > 6 || (parts[0] == 6 && parts[1]! >= 1); } + NextcloudAccount copyWith({ + String? appVersion, + String? apiVersion, + bool? apiSupported, + Object? listEtag = _unchanged, + Object? lastModified = _unchanged, + DateTime? lastServerCheck, + DateTime? capabilitiesCheckedAt, + Object? settingsAttempt = _unchanged, + Object? throttleNotBefore = _unchanged, + Object? notesWebPrettyUrls = _unchanged, + }) => NextcloudAccount( + id: id, + server: server, + loginName: loginName, + appVersion: appVersion ?? this.appVersion, + apiVersion: apiVersion ?? this.apiVersion, + apiSupported: apiSupported ?? this.apiSupported, + listEtag: identical(listEtag, _unchanged) + ? this.listEtag + : listEtag as String?, + lastModified: identical(lastModified, _unchanged) + ? this.lastModified + : lastModified as String?, + lastServerCheck: lastServerCheck ?? this.lastServerCheck, + capabilitiesCheckedAt: capabilitiesCheckedAt ?? this.capabilitiesCheckedAt, + settingsAttempt: identical(settingsAttempt, _unchanged) + ? this.settingsAttempt + : settingsAttempt as NotesSettingsAttempt?, + throttleNotBefore: identical(throttleNotBefore, _unchanged) + ? this.throttleNotBefore + : throttleNotBefore as DateTime?, + notesWebPrettyUrls: identical(notesWebPrettyUrls, _unchanged) + ? this.notesWebPrettyUrls + : notesWebPrettyUrls as bool?, + ); NextcloudAccount withCheckpoint(String? etag, String? modified) => - NextcloudAccount( - id: id, - server: server, - loginName: loginName, - appVersion: appVersion, - apiVersion: apiVersion, - listEtag: etag, - lastModified: modified, - ); + copyWith(listEtag: etag, lastModified: modified); Map toJson() => { 'id': id, @@ -99,6 +201,12 @@ class NextcloudAccount { 'apiVersion': apiVersion, 'listEtag': listEtag, 'lastModified': lastModified, + 'lastServerCheck': lastServerCheck?.toIso8601String(), + 'capabilitiesCheckedAt': capabilitiesCheckedAt?.toIso8601String(), + 'apiSupported': apiSupported, + 'settingsAttempt': settingsAttempt?.toJson(), + 'throttleNotBefore': throttleNotBefore?.toIso8601String(), + if (notesWebPrettyUrls != null) 'notesWebPrettyUrls': notesWebPrettyUrls, }; factory NextcloudAccount.fromJson(Map json) => @@ -110,6 +218,22 @@ class NextcloudAccount { apiVersion: json['apiVersion'] as String? ?? '1.4', listEtag: json['listEtag'] as String?, lastModified: json['lastModified'] as String?, + lastServerCheck: DateTime.tryParse( + json['lastServerCheck'] as String? ?? '', + ), + capabilitiesCheckedAt: DateTime.tryParse( + json['capabilitiesCheckedAt'] as String? ?? '', + ), + apiSupported: json['apiSupported'] as bool? ?? true, + notesWebPrettyUrls: json['notesWebPrettyUrls'] as bool?, + settingsAttempt: json['settingsAttempt'] == null + ? null + : NotesSettingsAttempt.fromJson( + Map.from(json['settingsAttempt'] as Map), + ), + throttleNotBefore: DateTime.tryParse( + json['throttleNotBefore'] as String? ?? '', + ), ); } @@ -297,6 +421,67 @@ class NotesEditorBase { final int generation; } +/// Metadata values from one accepted local edit, independent of server evidence. +class NotesMetadataValues { + const NotesMetadataValues({ + required this.title, + required this.category, + required this.favorite, + }); + factory NotesMetadataValues.fromNote(NextcloudNote note) => + NotesMetadataValues( + title: note.title, + category: note.category, + favorite: note.favorite, + ); + final String title; + final String category; + final bool favorite; + NotesMetadataValues copyWith({ + String? title, + String? category, + bool? favorite, + }) => NotesMetadataValues( + title: title ?? this.title, + category: category ?? this.category, + favorite: favorite ?? this.favorite, + ); + Map toJson() => { + 'title': title, + 'category': category, + 'favorite': favorite, + }; + factory NotesMetadataValues.fromJson(Map json) => + NotesMetadataValues( + title: json['title'] as String, + category: json['category'] as String, + favorite: json['favorite'] as bool, + ); +} + +/// Competing local properties edits. Neither side is an observation of Nextcloud. +class NotesMetadataConflict { + const NotesMetadataConflict({ + required this.original, + required this.alternative, + }); + final NotesMetadataValues original; + final NotesMetadataValues alternative; + Map toJson() => { + 'original': original.toJson(), + 'alternative': alternative.toJson(), + }; + factory NotesMetadataConflict.fromJson(Map json) => + NotesMetadataConflict( + original: NotesMetadataValues.fromJson( + Map.from(json['original'] as Map), + ), + alternative: NotesMetadataValues.fromJson( + Map.from(json['alternative'] as Map), + ), + ); +} + class NextcloudNote { const NextcloudNote({ required this.localId, @@ -312,13 +497,21 @@ class NextcloudNote { this.etag, this.base, this.remote, + this.metadataConflict, this.creationAttempt, + this.creationNeverSent = false, this.revision = 1, this.ackRevision = 0, this.editorRevision = 0, this.editorContentDigest, this.syncState = NoteSyncState.pending, this.errorMessage, + this.localActivityMicros, + this.failureCode, + this.failureScope, + this.retryNotBefore, + this.retryCount = 0, + this.deletionEvidence, }); final String localId; @@ -334,7 +527,11 @@ class NextcloudNote { final String? etag; final NoteState? base; final NoteState? remote; + final NotesMetadataConflict? metadataConflict; final NotesCreationAttempt? creationAttempt; + + /// Positive durable evidence; missing legacy metadata is not proof. + final bool creationNeverSent; final int revision; final int ackRevision; @@ -343,8 +540,20 @@ class NextcloudNote { final String? editorContentDigest; final NoteSyncState syncState; final String? errorMessage; + final int? localActivityMicros; + final NotesFailureCode? failureCode; + final NotesRequestScope? failureScope; + final DateTime? retryNotBefore; + final int retryCount; + final String? deletionEvidence; + int get activityMicros => localActivityMicros == null + ? modified * 1000000 + : (localActivityMicros! > modified * 1000000 + ? localActivityMicros! + : modified * 1000000); bool get hasPendingChanges => + metadataConflict != null || revision > ackRevision || (serverId == null && syncState != NoteSyncState.deletedRemotely); String get identity => 'nextcloud-note:$accountId:$localId'; @@ -361,13 +570,21 @@ class NextcloudNote { Object? etag = _unchanged, Object? base = _unchanged, Object? remote = _unchanged, + Object? metadataConflict = _unchanged, Object? creationAttempt = _unchanged, + bool? creationNeverSent, int? revision, int? ackRevision, int? editorRevision, Object? editorContentDigest = _unchanged, NoteSyncState? syncState, Object? errorMessage = _unchanged, + Object? localActivityMicros = _unchanged, + Object? failureCode = _unchanged, + Object? failureScope = _unchanged, + Object? retryNotBefore = _unchanged, + int? retryCount, + Object? deletionEvidence = _unchanged, }) => NextcloudNote( localId: localId, accountId: accountId, @@ -384,6 +601,10 @@ class NextcloudNote { etag: identical(etag, _unchanged) ? this.etag : etag as String?, base: identical(base, _unchanged) ? this.base : base as NoteState?, remote: identical(remote, _unchanged) ? this.remote : remote as NoteState?, + metadataConflict: identical(metadataConflict, _unchanged) + ? this.metadataConflict + : metadataConflict as NotesMetadataConflict?, + creationNeverSent: creationNeverSent ?? this.creationNeverSent, creationAttempt: identical(creationAttempt, _unchanged) ? this.creationAttempt : creationAttempt as NotesCreationAttempt?, @@ -393,6 +614,22 @@ class NextcloudNote { editorContentDigest: identical(editorContentDigest, _unchanged) ? this.editorContentDigest : editorContentDigest as String?, + localActivityMicros: identical(localActivityMicros, _unchanged) + ? this.localActivityMicros + : localActivityMicros as int?, + failureCode: identical(failureCode, _unchanged) + ? this.failureCode + : failureCode as NotesFailureCode?, + failureScope: identical(failureScope, _unchanged) + ? this.failureScope + : failureScope as NotesRequestScope?, + retryNotBefore: identical(retryNotBefore, _unchanged) + ? this.retryNotBefore + : retryNotBefore as DateTime?, + retryCount: retryCount ?? this.retryCount, + deletionEvidence: identical(deletionEvidence, _unchanged) + ? this.deletionEvidence + : deletionEvidence as String?, syncState: syncState ?? this.syncState, errorMessage: identical(errorMessage, _unchanged) ? this.errorMessage @@ -413,13 +650,21 @@ class NextcloudNote { 'etag': etag, 'base': base?.toJson(), 'remote': remote?.toJson(), + 'metadataConflict': metadataConflict?.toJson(), 'creationAttempt': creationAttempt?.toJson(), + 'creationNeverSent': creationNeverSent, 'revision': revision, 'ackRevision': ackRevision, 'editorRevision': editorRevision, 'editorContentDigest': editorContentDigest, 'syncState': syncState.name, 'errorMessage': errorMessage, + 'localActivityMicros': localActivityMicros, + 'failureCode': failureCode?.name, + 'failureScope': failureScope?.name, + 'retryNotBefore': retryNotBefore?.toIso8601String(), + 'retryCount': retryCount, + 'deletionEvidence': deletionEvidence, }; factory NextcloudNote.fromJson(Map json) => NextcloudNote( @@ -440,6 +685,12 @@ class NextcloudNote { remote: json['remote'] == null ? null : NoteState.fromJson(Map.from(json['remote'] as Map)), + metadataConflict: json['metadataConflict'] == null + ? null + : NotesMetadataConflict.fromJson( + Map.from(json['metadataConflict'] as Map), + ), + creationNeverSent: json['creationNeverSent'] == true, creationAttempt: json['creationAttempt'] == null ? null : NotesCreationAttempt.fromJson( @@ -451,6 +702,16 @@ class NextcloudNote { editorContentDigest: json['editorContentDigest'] as String?, syncState: NoteSyncState.values.byName(json['syncState'] as String), errorMessage: json['errorMessage'] as String?, + localActivityMicros: json['localActivityMicros'] as int?, + failureCode: json['failureCode'] == null + ? null + : NotesFailureCode.values.byName(json['failureCode'] as String), + failureScope: json['failureScope'] == null + ? null + : NotesRequestScope.values.byName(json['failureScope'] as String), + retryNotBefore: DateTime.tryParse(json['retryNotBefore'] as String? ?? ''), + retryCount: json['retryCount'] as int? ?? 0, + deletionEvidence: json['deletionEvidence'] as String?, ); String encode() => jsonEncode(toJson()); @@ -464,6 +725,7 @@ class NotesAttachment { required this.reference, this.remotePath, this.state = 'pending', + this.validatedAt, }); final String id; @@ -472,6 +734,7 @@ class NotesAttachment { final String reference; final String? remotePath; final String state; + final DateTime? validatedAt; Map toJson() => { 'id': id, @@ -480,6 +743,7 @@ class NotesAttachment { 'reference': reference, 'remotePath': remotePath, 'state': state, + 'validatedAt': validatedAt?.toIso8601String(), }; factory NotesAttachment.fromJson(Map json) => @@ -490,5 +754,60 @@ class NotesAttachment { reference: json['reference'] as String, remotePath: json['remotePath'] as String?, state: json['state'] as String, + validatedAt: DateTime.tryParse(json['validatedAt'] as String? ?? ''), + ); +} + +/// Immutable properties-dialog provenance; patches contain only edited fields. +class NotesMetadataSnapshot { + const NotesMetadataSnapshot(this.note, this.base); + final NextcloudNote note; + final NotesEditorBase base; +} + +class NotesSettings { + const NotesSettings({required this.notesPath, required this.fileSuffix}); + final String notesPath; + final String fileSuffix; + factory NotesSettings.fromJson(dynamic json) { + if (json is! Map || + json['notesPath'] is! String || + json['fileSuffix'] is! String) { + throw const NotesException( + NotesFailureCode.invalidResponse, + 'Nextcloud returned invalid Notes settings.', + scope: NotesRequestScope.settings, + ); + } + return NotesSettings( + notesPath: json['notesPath'] as String, + fileSuffix: json['fileSuffix'] as String, + ); + } + Map toJson() => { + 'notesPath': notesPath, + 'fileSuffix': fileSuffix, + }; +} + +class NotesSettingsAttempt { + NotesSettingsAttempt({ + required this.id, + required this.original, + required Map patch, + }) : patch = Map.unmodifiable(patch); + final String id; + final NotesSettings original; + final Map patch; + Map toJson() => { + 'id': id, + 'original': original.toJson(), + 'patch': patch, + }; + factory NotesSettingsAttempt.fromJson(Map json) => + NotesSettingsAttempt( + id: json['id'] as String, + original: NotesSettings.fromJson(json['original']), + patch: Map.from(json['patch'] as Map), ); } diff --git a/lib/src/nextcloud_notes/domain/notes_retrieval.dart b/lib/src/nextcloud_notes/domain/notes_retrieval.dart new file mode 100644 index 00000000..6f89eb21 --- /dev/null +++ b/lib/src/nextcloud_notes/domain/notes_retrieval.dart @@ -0,0 +1,82 @@ +import 'notes_models.dart'; + +/// A validator always belongs to a complete, cached server representation. +sealed class NotesGetResult { + const NotesGetResult(); +} + +final class NotesReturned extends NotesGetResult { + const NotesReturned(this.note); + final NoteState note; +} + +final class NotesNotModified extends NotesGetResult { + const NotesNotModified(this.cached); + final NoteState cached; +} + +/// Optional reads are deliberately separate from complete-account checkpoints. +class NotesRetrievalScope { + NotesRetrievalScope({this.category, Set exclude = const {}}) + : exclude = Set.unmodifiable(exclude) { + if (exclude.any((field) => !fields.contains(field))) { + throw ArgumentError('Invalid excluded Notes field.'); + } + } + + static const fields = { + 'id', + 'etag', + 'content', + 'title', + 'category', + 'favorite', + 'readonly', + 'modified', + 'error', + }; + final String? category; + final Set exclude; + Map get query => { + if (category != null) 'category': category!, + if (exclude.isNotEmpty) 'exclude': (exclude.toList()..sort()).join(','), + }; +} + +/// Presence is retained; an omitted field has no value or fabricated default. +class NotesProjection { + NotesProjection.fromJson(Map json) + : values = Map.unmodifiable(json) { + if (json.containsKey('id') && + (json['id'] is! int || (json['id'] as int) <= 0)) { + throw const NotesException( + NotesFailureCode.invalidResponse, + 'Nextcloud returned an invalid note identifier.', + ); + } + for (final entry in json.entries) { + final valid = switch (entry.key) { + 'id' || 'modified' => entry.value is int, + 'etag' || 'content' || 'title' || 'category' => entry.value is String, + 'favorite' || 'readonly' || 'error' => entry.value is bool, + _ => true, + }; + if (!valid) { + throw const NotesException( + NotesFailureCode.invalidResponse, + 'Nextcloud returned an invalid projected note field.', + ); + } + } + } + final Map values; + int? get id => values['id'] as int?; + bool has(String field) => values.containsKey(field); +} + +class NotesQueryResult { + NotesQueryResult({required this.scope, required List notes}) + : notes = List.unmodifiable(notes); + final NotesRetrievalScope scope; + final List notes; +} diff --git a/lib/src/nextcloud_notes/domain/notes_search.dart b/lib/src/nextcloud_notes/domain/notes_search.dart new file mode 100644 index 00000000..e5b3c40b --- /dev/null +++ b/lib/src/nextcloud_notes/domain/notes_search.dart @@ -0,0 +1,330 @@ +import 'dart:convert'; + +import 'package:characters/characters.dart'; +import 'package:crypto/crypto.dart'; +import 'package:unorm_dart/unorm_dart.dart' as unorm; + +String normalizeNotesSearch(String value) => unorm.nfc(value).toLowerCase(); + +class NotesSearchTerm { + const NotesSearchTerm(this.text, this.field); + final String text; + final String? field; + String get literal => normalizeNotesSearch(text); +} + +/// Only these two field prefixes and quotes are grammar. Everything else, +/// including SQL/FTS operators, punctuation and wildcards, is literal text. +class NotesSearchQuery { + NotesSearchQuery(this.text, {this.wholeWord = false}) { + if (text.length > 512) throw const FormatException('Search is too long.'); + if ('"'.allMatches(text).length.isOdd) { + throw const FormatException('Close the quoted phrase.'); + } + final tokens = RegExp(r'(title:|category:)?("[^"]*"|[^\s]+)'); + terms = [ + for (final token in tokens.allMatches(text)) + if (token.group(2)!.replaceAll('"', '').isNotEmpty) + NotesSearchTerm( + token.group(2)!.startsWith('"') + ? token.group(2)!.substring(1, token.group(2)!.length - 1) + : token.group(2)!, + token.group(1)?.replaceAll(':', ''), + ), + ]; + if (terms.length > 32) { + throw const FormatException('Too many search terms.'); + } + } + final String text; + final bool wholeWord; + late final List terms; + String? get ftsCandidates { + final indexable = terms.where((t) => t.literal.runes.length >= 3); + if (indexable.isEmpty) return null; + return indexable + .map((t) { + final quoted = '"${t.literal.replaceAll('"', '""')}"'; + return t.field == null ? quoted : '${t.field}:$quoted'; + }) + .join(' AND '); + } +} + +class NotesSearchHit { + const NotesSearchHit({ + required this.localId, + required this.revision, + required this.digest, + required this.title, + required this.category, + required this.snippet, + required this.snippetStart, + this.start, + this.end, + this.snippetMatchStart, + this.snippetMatchEnd, + }); + final String localId; + final int revision; + final String digest; + final String title; + final String category; + final String snippet; + final int snippetStart; + final int? start; + final int? end; + final int? snippetMatchStart, snippetMatchEnd; + Map toJson() => { + 'id': localId, + 'revision': revision, + 'digest': digest, + 'title': title, + 'category': category, + 'snippet': snippet, + 'snippetStart': snippetStart, + 'start': start, + 'end': end, + 'snippetMatchStart': snippetMatchStart, + 'snippetMatchEnd': snippetMatchEnd, + }; + factory NotesSearchHit.fromJson(Map json) => NotesSearchHit( + localId: json['id'] as String, + revision: json['revision'] as int, + digest: json['digest'] as String, + title: json['title'] as String, + category: json['category'] as String, + snippet: json['snippet'] as String, + snippetStart: json['snippetStart'] as int, + start: json['start'] as int?, + end: json['end'] as int?, + snippetMatchStart: json['snippetMatchStart'] as int?, + snippetMatchEnd: json['snippetMatchEnd'] as int?, + ); +} + +String notesSearchDigest(String source) => + sha256.convert(utf8.encode(source)).toString(); +final _word = RegExp(r'[\p{L}\p{M}\p{N}_]', unicode: true); + +Iterable<({int start, int end})> _ranges( + String normalized, + String term, + bool wholeWord, +) sync* { + var position = 0; + while (position <= normalized.length) { + final start = normalized.indexOf(term, position); + if (start < 0) break; + final end = start + term.length; + // Unicode identifier boundaries; punctuation in the query remains literal. + if (!wholeWord || + ((start == 0 || + !_word.hasMatch( + String.fromCharCode(normalized.runesBefore(start)), + )) && + (end == normalized.length || + !_word.hasMatch( + String.fromCharCode(normalized.runesAt(end)), + )))) { + yield (start: start, end: end); + } + position = end; + } +} + +extension on String { + int runesBefore(int offset) { + var start = offset - 1; + if (start > 0 && + codeUnitAt(start) >= 0xdc00 && + codeUnitAt(start) <= 0xdfff) { + start--; + } + return substring(start, offset).runes.first; + } + + int runesAt(int offset) => substring(offset).runes.first; +} + +/// Shared verifier for indexed durable documents, dirty overlays and stale +/// navigation. Offsets map back through grapheme clusters to authored UTF-16. +List matchNotesDocument({ + required NotesSearchQuery query, + required String localId, + required int revision, + required String title, + required String category, + required String source, + int limit = 80, + int afterStart = -1, + int afterEnd = -1, +}) { + if (query.terms.isEmpty || limit <= 0) return []; + final body = normalizeNotesSearch(source); + final normalizedTitle = normalizeNotesSearch(title); + final normalizedCategory = normalizeNotesSearch(category); + final iterators = >[]; + ({String text, int start, int end})? metadataMatch; + for (final term in query.terms) { + final contentMatches = + (term.field == null + ? _ranges(body, term.literal, query.wholeWord) + : const <({int start, int end})>[]) + .iterator; + final hasContent = contentMatches.moveNext(); + final titleMatches = term.field != 'category' + ? _ranges(normalizedTitle, term.literal, query.wholeWord) + : <({int start, int end})>[]; + final categoryMatches = term.field != 'title' + ? _ranges(normalizedCategory, term.literal, query.wholeWord) + : <({int start, int end})>[]; + if (!hasContent && titleMatches.isEmpty && categoryMatches.isEmpty) { + return []; + } + if (metadataMatch == null && titleMatches.isNotEmpty) { + metadataMatch = ( + text: title, + start: titleMatches.first.start, + end: titleMatches.first.end, + ); + } else if (metadataMatch == null && categoryMatches.isNotEmpty) { + metadataMatch = ( + text: category, + start: categoryMatches.first.start, + end: categoryMatches.first.end, + ); + } + if (hasContent) iterators.add(contentMatches); + } + final digest = notesSearchDigest(source); + if (iterators.isEmpty) { + if (afterStart >= 0) return []; + final match = metadataMatch!; + var normalizedOffset = 0, originalOffset = 0, from = 0, to = 0; + for (final cluster in match.text.characters) { + final length = normalizeNotesSearch(cluster).length; + if (match.start >= normalizedOffset && + match.start < normalizedOffset + length) { + from = originalOffset; + } + if (match.end > normalizedOffset && + match.end <= normalizedOffset + length) { + to = originalOffset + cluster.length; + } + normalizedOffset += length; + originalOffset += cluster.length; + } + return [ + NotesSearchHit( + localId: localId, + revision: revision, + digest: digest, + title: title, + category: category, + snippet: match.text, + snippetStart: 0, + snippetMatchStart: from, + snippetMatchEnd: to, + ), + ]; + } + // Merge lazy per-term streams. Bounds limit transferred hits, never the + // searchable occurrences. A range cursor also distinguishes overlapping terms. + final ranges = <({int start, int end})>[]; + final normalizedAfterStart = afterStart < 0 + ? -1 + : normalizeNotesSearch(source.substring(0, afterStart)).length; + final normalizedAfterEnd = afterEnd < 0 + ? -1 + : normalizeNotesSearch(source.substring(0, afterEnd)).length; + ({int start, int end})? previous; + while (iterators.isNotEmpty && ranges.length < limit) { + iterators.sort((a, b) { + final start = a.current.start.compareTo(b.current.start); + return start != 0 ? start : a.current.end.compareTo(b.current.end); + }); + final iterator = iterators.first; + final range = iterator.current; + if (range != previous && + (range.start > normalizedAfterStart || + range.start == normalizedAfterStart && + range.end > normalizedAfterEnd)) { + ranges.add(range); + } + previous = range; + if (!iterator.moveNext()) iterators.removeAt(0); + } + // Map only page boundaries through authored graphemes, including overlapping + // occurrences. No array proportional to document length is transferred. + final startBoundaries = ranges.map((r) => r.start).toSet().toList()..sort(); + final endBoundaries = ranges.map((r) => r.end).toSet().toList()..sort(); + final starts = {}, ends = {}; + var normalizedOffset = 0, originalOffset = 0, startIndex = 0, endIndex = 0; + for (final cluster in source.characters) { + final normalizedEnd = + normalizedOffset + normalizeNotesSearch(cluster).length; + while (startIndex < startBoundaries.length && + startBoundaries[startIndex] < normalizedEnd) { + starts[startBoundaries[startIndex++]] = originalOffset; + } + while (endIndex < endBoundaries.length && + endBoundaries[endIndex] <= normalizedEnd) { + ends[endBoundaries[endIndex++]] = originalOffset + cluster.length; + } + if (endIndex == endBoundaries.length) break; + normalizedOffset = normalizedEnd; + originalOffset += cluster.length; + } + final hits = [ + for (final range in ranges) + if (starts.containsKey(range.start) && ends.containsKey(range.end)) + _hit( + localId, + revision, + digest, + title, + category, + source, + starts[range.start]!, + ends[range.end]!, + ), + ]; + return hits; +} + +NotesSearchHit _hit( + String id, + int revision, + String digest, + String title, + String category, + String source, + int start, + int end, +) { + var snippetStart = (start - 45).clamp(0, source.length); + var snippetEnd = (end + 100).clamp(0, source.length); + // Snippets must not split surrogate pairs. + if (snippetStart > 0 && + source.codeUnitAt(snippetStart) >= 0xdc00 && + source.codeUnitAt(snippetStart) <= 0xdfff) { + snippetStart--; + } + if (snippetEnd < source.length && + source.codeUnitAt(snippetEnd) >= 0xdc00 && + source.codeUnitAt(snippetEnd) <= 0xdfff) { + snippetEnd++; + } + return NotesSearchHit( + localId: id, + revision: revision, + digest: digest, + title: title, + category: category, + snippet: source.substring(snippetStart, snippetEnd), + snippetStart: snippetStart, + start: start, + end: end, + ); +} diff --git a/lib/src/nextcloud_notes/presentation/nextcloud_settings.dart b/lib/src/nextcloud_notes/presentation/nextcloud_settings.dart index 17070c05..a6c7822e 100644 --- a/lib/src/nextcloud_notes/presentation/nextcloud_settings.dart +++ b/lib/src/nextcloud_notes/presentation/nextcloud_settings.dart @@ -10,6 +10,8 @@ import '../../app/localization.dart'; import '../../workspace/workspace_controller.dart'; import '../../workspace/workspace_safety.dart'; import '../application/nextcloud_connection.dart'; +import '../application/notes_settings_controller.dart'; +import 'notes_sidebar.dart' show nextcloudNotesChangesProvider; class NextcloudNotesSettings extends ConsumerStatefulWidget { const NextcloudNotesSettings({super.key}); @@ -48,7 +50,13 @@ class _NextcloudNotesSettingsState Widget build(BuildContext context) { final connection = ref.watch(nextcloudConnectionProvider); final controller = ref.read(nextcloudConnectionProvider.notifier); - final account = connection.account; + ref.watch(nextcloudNotesChangesProvider); + final repository = ref.watch(nextcloudNotesRepositoryProvider).value; + final account = connection.account == null + ? null + : repository == null + ? connection.account + : repository.accountById(connection.account!.id); return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -115,6 +123,14 @@ class _NextcloudNotesSettingsState ), ], ), + if (account != null) ...[ + _ServerNotesSettings(accountId: account.id), + if (repository?.accountError(account.id) case final error?) + BusyMarkStatusBox( + message: error.message, + kind: BusyMarkStatusKind.error, + ), + ], if (connection.error != null) BusyMarkStatusBox( message: connection.error!, @@ -162,3 +178,141 @@ class _NextcloudNotesSettingsState } } } + +class _ServerNotesSettings extends ConsumerStatefulWidget { + const _ServerNotesSettings({required this.accountId}); + final String accountId; + @override + ConsumerState<_ServerNotesSettings> createState() => + _ServerNotesSettingsState(); +} + +class _ServerNotesSettingsState extends ConsumerState<_ServerNotesSettings> { + final _path = TextEditingController(); + final _suffix = TextEditingController(); + @override + void initState() { + super.initState(); + Future.microtask(() { + if (mounted) { + unawaited( + ref.read(notesSettingsProvider.notifier).load(widget.accountId), + ); + } + }); + } + + @override + void didUpdateWidget(_ServerNotesSettings oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.accountId != oldWidget.accountId) { + unawaited( + ref + .read(notesSettingsProvider.notifier) + .load(widget.accountId, discard: true), + ); + } + } + + @override + void dispose() { + _path.dispose(); + _suffix.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final settings = ref.watch(notesSettingsProvider); + final controller = ref.read(notesSettingsProvider.notifier); + final draft = settings.draft; + if (draft != null) { + if (_path.text != draft.notesPath) _path.text = draft.notesPath; + if (_suffix.text != draft.fileSuffix) _suffix.text = draft.fileSuffix; + } + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + BusyMarkGroupedList( + title: context.l10n.nextcloudServerSettings, + filled: true, + children: [ + BusyMarkGroupedTextEntry( + key: const ValueKey('notes-settings-path'), + label: context.l10n.nextcloudNotesPath, + controller: _path, + enabled: draft != null && !settings.busy, + onChanged: (value) => controller.edit(notesPath: value), + ), + BusyMarkGroupedTextEntry( + key: const ValueKey('notes-settings-suffix'), + label: context.l10n.nextcloudFileSuffix, + controller: _suffix, + enabled: draft != null && !settings.busy, + onChanged: (value) => controller.edit(fileSuffix: value), + ), + ], + ), + Text(context.l10n.nextcloudServerSettingsExplanation), + if (settings.busy) ...[ + Semantics( + liveRegion: true, + child: Text( + settings.phase == NotesSettingsPhase.loading + ? context.l10n.nextcloudSettingsLoading + : context.l10n.nextcloudSettingsSaving, + ), + ), + LinearProgressIndicator( + semanticsLabel: settings.phase == NotesSettingsPhase.loading + ? context.l10n.nextcloudSettingsLoading + : context.l10n.nextcloudSettingsSaving, + ), + ], + if (settings.error != null) + BusyMarkStatusBox( + message: settings.error!, + kind: BusyMarkStatusKind.error, + ), + if (settings.phase == NotesSettingsPhase.saved) + Text( + settings.normalized + ? context.l10n.nextcloudSettingsNormalized + : context.l10n.nextcloudSettingsSaved, + ), + if (settings.dirty) Text(context.l10n.closeUnsavedChangesTitle), + Row( + children: [ + BusyMarkPushButton.standard( + key: const ValueKey('notes-settings-save'), + onPressed: settings.dirty && !settings.busy + ? () => unawaited( + controller.save( + preserveBuffers: () async => + (await ref + .read(workspaceControllerProvider.notifier) + .saveAll()) + .succeeded, + ), + ) + : null, + child: Text(context.l10n.save), + ), + BusyMarkPushButton.standard( + onPressed: settings.dirty && !settings.busy + ? controller.cancel + : null, + child: Text(context.l10n.cancel), + ), + BusyMarkPushButton.standard( + onPressed: !settings.busy && !settings.dirty + ? () => unawaited(controller.load(widget.accountId)) + : null, + child: Text(context.l10n.visualizationRetry), + ), + ], + ), + ], + ); + } +} diff --git a/lib/src/nextcloud_notes/presentation/notes_sidebar.dart b/lib/src/nextcloud_notes/presentation/notes_sidebar.dart index ec2db9b9..7cb6a4b4 100644 --- a/lib/src/nextcloud_notes/presentation/notes_sidebar.dart +++ b/lib/src/nextcloud_notes/presentation/notes_sidebar.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:file_selector/file_selector.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:yaru/yaru.dart'; @@ -16,13 +17,19 @@ import '../../workspace/workspace_controller.dart'; import '../../workspace/workspace_message.dart'; import '../../workspace/workspace_safety.dart'; import '../application/nextcloud_connection.dart'; +import '../application/notes_navigation.dart'; +import 'notes_workspace_ui.dart'; import '../application/attachment_markdown.dart'; import '../domain/notes_models.dart'; import '../domain/notes_conflict.dart'; +import '../data/notes_attachment_references.dart'; -final nextcloudNotesChangesProvider = StreamProvider((ref) async* { +final nextcloudNotesChangesProvider = StreamProvider((ref) async* { final repository = await ref.watch(nextcloudNotesRepositoryProvider.future); - yield* repository.changes; + var generation = 0; + await for (final _ in repository.changes) { + yield ++generation; + } }); String nextcloudSyncLabel( @@ -40,9 +47,26 @@ String nextcloudSyncLabel( NoteSyncState.deletedRemotely => context.l10n.localHistoryDeleted, NoteSyncState.forbidden => context.l10n.nextcloudReadOnly, NoteSyncState.storageFull || - NoteSyncState.unavailable => context.l10n.warning, + NoteSyncState.unavailable || + NoteSyncState.rejected || + NoteSyncState.throttled || + NoteSyncState.recoveryRequired => context.l10n.warning, }; +// Legacy response transitions may have left a transient/synchronized state +// alongside durable review evidence. That evidence still takes precedence. +bool _metadataNeedsReview(NextcloudNote note) => + note.metadataConflict != null && + { + NoteSyncState.synced, + NoteSyncState.pending, + NoteSyncState.syncing, + NoteSyncState.offline, + NoteSyncState.locked, + NoteSyncState.throttled, + NoteSyncState.conflict, + }.contains(note.syncState); + class NextcloudNotesSidebar extends ConsumerStatefulWidget { const NextcloudNotesSidebar({super.key, required this.accountId}); final String accountId; @@ -52,185 +76,659 @@ class NextcloudNotesSidebar extends ConsumerStatefulWidget { } class _NextcloudNotesSidebarState extends ConsumerState { - String _query = ''; - bool _favorites = false; - String? _category; + final _navigation = NotesNavigationController(); + final _listFocus = FocusNode(); + final _listScroll = ScrollController(); + StreamSubscription? _notesSubscription; + bool _ready = false; + + @override + void initState() { + super.initState(); + unawaited(_initializeNavigation()); + } + + @override + void didUpdateWidget(covariant NextcloudNotesSidebar oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.accountId != widget.accountId) { + unawaited(_notesSubscription?.cancel()); + _ready = false; + unawaited(_initializeNavigation()); + } + } + + Future _initializeNavigation() async { + final accountId = widget.accountId; + final repository = await ref.read(nextcloudNotesRepositoryProvider.future); + if (!mounted || accountId != widget.accountId) return; + _navigation.update(repository.notes, widget.accountId); + _notesSubscription = repository.changes.listen((_) { + if (mounted) { + setState(() => _navigation.update(repository.notes, widget.accountId)); + } + }); + setState(() => _ready = true); + } + + void _refreshNavigation() { + final repository = ref.read(nextcloudNotesRepositoryProvider).value; + if (repository != null) { + _navigation.update(repository.notes, widget.accountId); + } + } + + @override + void dispose() { + unawaited(_notesSubscription?.cancel()); + _listFocus.dispose(); + _listScroll.dispose(); + super.dispose(); + } + + KeyEventResult _listKey(FocusNode node, KeyEvent event) { + if (event is! KeyDownEvent) return KeyEventResult.ignored; + final keyboard = HardwareKeyboard.instance; + if (keyboard.isControlPressed && + event.logicalKey == LogicalKeyboardKey.keyA) { + setState(_navigation.selectAll); + return KeyEventResult.handled; + } + final visible = _navigation.visible; + if (visible.isEmpty) return KeyEventResult.ignored; + if (event.logicalKey == LogicalKeyboardKey.arrowDown || + event.logicalKey == LogicalKeyboardKey.arrowUp) { + final index = visible.indexWhere((n) => n.localId == _navigation.focused); + final next = + (index + (event.logicalKey == LogicalKeyboardKey.arrowDown ? 1 : -1)) + .clamp(0, visible.length - 1); + setState( + () => _navigation.select( + visible[next].localId, + range: keyboard.isShiftPressed, + toggle: keyboard.isControlPressed, + ), + ); + if (_listScroll.hasClients) { + unawaited( + _listScroll.animateTo( + (next * 100.0).clamp(0, _listScroll.position.maxScrollExtent), + duration: const Duration(milliseconds: 120), + curve: Curves.easeOut, + ), + ); + } + return KeyEventResult.handled; + } + if (event.logicalKey == LogicalKeyboardKey.space && + _navigation.focused != null) { + setState(() => _navigation.select(_navigation.focused!, toggle: true)); + return KeyEventResult.handled; + } + if (event.logicalKey == LogicalKeyboardKey.enter && + _navigation.focused != null) { + final note = visible.firstWhere((n) => n.localId == _navigation.focused); + unawaited( + isNotesRecovery(note) + ? showNotesRecovery(context, ref, note) + : ref + .read(workspaceControllerProvider.notifier) + .openNextcloudNote(note.localId), + ); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + } @override Widget build(BuildContext context) { - ref.watch(nextcloudNotesChangesProvider); - final repository = ref.watch(nextcloudNotesRepositoryProvider); - final active = ref - .watch(workspaceControllerProvider) - .activeBuffer - ?.remoteNote - ?.localId; - return repository.when( - loading: () => const Center(child: CircularProgressIndicator()), - error: (error, stack) => Padding( - padding: const EdgeInsets.all(BusyMarkSpacing.md), - child: Text(error.toString()), + final repository = ref.watch(nextcloudNotesRepositoryProvider).value; + final active = ref.watch( + workspaceControllerProvider.select( + (s) => s.activeBuffer?.remoteNote?.localId, ), - data: (repository) { - final all = repository.notes - .where( - (n) => - n.accountId == widget.accountId && - !(n.syncState == NoteSyncState.deletedRemotely && - !n.hasPendingChanges), - ) - .toList(); - final categories = {}; - for (final note in all) { - final pieces = note.category.split('/'); - for (var i = 1; i <= pieces.length; i++) { - final category = pieces.take(i).join('/'); - if (category.isNotEmpty) categories.add(category); - } - } - // A move/delete can remove the selected category, including the last - // category that made the filter control visible. - if (!categories.contains(_category)) _category = null; - final sortedCategories = categories.toList()..sort(); - final query = _query.toLowerCase(); - final visible = - all - .where( - (n) => - (!_favorites || n.favorite) && - (_category == null || - n.category == _category || - n.category.startsWith('$_category/')) && - (query.isEmpty || - n.title.toLowerCase().contains(query) || - n.content.toLowerCase().contains(query) || - n.category.toLowerCase().contains(query)), - ) - .toList() - ..sort((a, b) => b.modified.compareTo(a.modified)); - final accountError = repository.accountError(widget.accountId); - return Column( - children: [ - if (accountError != null) - Padding( - padding: const EdgeInsets.all(BusyMarkSpacing.sm), - child: BusyMarkStatusBox( - message: accountError.message, - kind: BusyMarkStatusKind.warning, + ); + final offline = ref.watch(notesOfflineProvider(widget.accountId)).value; + ref.watch(notesOfflineChangesProvider(widget.accountId)); + if (repository == null || !_ready) { + return const Center(child: CircularProgressIndicator()); + } + final categories = _navigation.categoryPaths; + final categoryRows = categories + .where( + (category) => !_navigation.collapsed.any( + (parent) => category.startsWith('$parent/'), + ), + ) + .toList(); + final visible = _navigation.visible; + final accountError = repository.accountError(widget.accountId); + String destinationLabel(NotesDestination destination) => + switch (destination) { + NotesDestination.all => context.l10n.notesAll, + NotesDestination.favorites => context.l10n.nextcloudFavorites, + NotesDestination.uncategorized => context.l10n.notesUncategorized, + NotesDestination.recovery => context.l10n.notesRecovery, + NotesDestination.category => _navigation.category, + }; + return Column( + children: [ + if (accountError != null) + Padding( + padding: const EdgeInsets.all(BusyMarkSpacing.sm), + child: BusyMarkStatusBox( + message: accountError.message, + kind: BusyMarkStatusKind.warning, + ), + ), + Padding( + padding: const EdgeInsets.all(BusyMarkSpacing.sm), + child: Row( + children: [ + Expanded( + child: BusyMarkPushButton.standard( + onPressed: () => unawaited( + ref + .read(workspaceControllerProvider.notifier) + .createNextcloudNote( + title: context.l10n.nextcloudNewNote, + category: + _navigation.destination == + NotesDestination.category + ? _navigation.category + : '', + ), + ), + child: Text(context.l10n.nextcloudNewNote), ), ), - Padding( - padding: const EdgeInsets.all(BusyMarkSpacing.sm), - child: Row( - children: [ - Expanded( - child: BusyMarkPushButton.standard( - onPressed: () => unawaited( - ref - .read(workspaceControllerProvider.notifier) - .createNextcloudNote( - title: context.l10n.nextcloudNewNote, - ), + IconButton( + tooltip: context.l10n.tocSynchronize, + icon: const Icon(BusyMarkGlyphs.refresh), + onPressed: () => unawaited( + ref + .read(workspaceControllerProvider.notifier) + .refreshNextcloudNotes(), + ), + ), + PopupMenuButton( + tooltip: context.l10n.mainMenu, + onSelected: (action) { + if (action == 'export') { + unawaited( + showNotesExport( + context, + ref, + repository.notes + .where( + (n) => + n.accountId == widget.accountId && + !isNotesRecovery(n) && + (_navigation.destination != + NotesDestination.category || + categoryIncludes( + _navigation.category, + n.category, + )), + ) + .map((n) => n.localId) + .toList(), + ), + ); + } + if (action == 'import') { + unawaited(showNotesImport(context, ref)); + } + if (action == 'file') { + unawaited(showNotesImport(context, ref, folder: false)); + } + if (action == 'offline' && offline != null) { + unawaited( + offline.setRequirement( + 'category', + _navigation.destination == NotesDestination.category + ? _navigation.category + : '', + ), + ); + } + if (action == 'remove' && offline != null) { + unawaited( + offline.setRequirement( + 'category', + _navigation.destination == NotesDestination.category + ? _navigation.category + : '', + required: false, ), - child: Text(context.l10n.nextcloudNewNote), + ); + } + if (action == 'cancel' && offline != null) { + unawaited( + offline.setRequirement( + 'category', + _navigation.destination == NotesDestination.category + ? _navigation.category + : '', + paused: true, + ), + ); + } + if (action == 'retry' && offline != null) { + unawaited( + offline.setRequirement( + 'category', + _navigation.destination == NotesDestination.category + ? _navigation.category + : '', + ), + ); + } + }, + itemBuilder: (context) => [ + PopupMenuItem( + value: 'export', + child: Text(context.l10n.notesExport), + ), + PopupMenuItem( + value: 'import', + child: Text( + '${context.l10n.notesImport} · ${context.l10n.folder}', ), ), - IconButton( - tooltip: context.l10n.tocSynchronize, - icon: const Icon(BusyMarkGlyphs.refresh), - onPressed: () => unawaited( - ref - .read(workspaceControllerProvider.notifier) - .refreshNextcloudNotes(), + PopupMenuItem( + value: 'file', + child: Text( + '${context.l10n.notesImport} · ${context.l10n.fileTypeMarkdown}', ), ), + PopupMenuItem( + value: 'offline', + child: Text(context.l10n.notesOffline), + ), + PopupMenuItem( + value: 'remove', + child: Text(context.l10n.notesOfflineRemove), + ), + PopupMenuItem( + value: 'cancel', + child: Text(context.l10n.cancel), + ), + PopupMenuItem( + value: 'retry', + child: Text(context.l10n.visualizationRetry), + ), ], ), + ], + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: BusyMarkSpacing.sm), + child: TextField( + readOnly: true, + decoration: InputDecoration( + hintText: context.l10n.search, + prefixIcon: const Icon(BusyMarkGlyphs.search), ), - Padding( - padding: const EdgeInsets.symmetric( - horizontal: BusyMarkSpacing.sm, - ), - child: TextField( - decoration: InputDecoration( - hintText: context.l10n.search, - prefixIcon: const Icon(BusyMarkGlyphs.search), - ), - onChanged: (value) => setState(() => _query = value), + onTap: () => unawaited( + showNotesSearch( + context, + ref, + account: widget.accountId, + recovery: _navigation.destination == NotesDestination.recovery, ), ), - CheckboxListTile( - dense: true, - title: Text(context.l10n.nextcloudFavorites), - value: _favorites, - onChanged: (value) => setState(() => _favorites = value ?? false), - ), - if (categories.isNotEmpty) - Padding( - padding: const EdgeInsets.symmetric( - horizontal: BusyMarkSpacing.sm, + ), + ), + SizedBox( + height: ((categoryRows.length + 4) * 36.0).clamp(0, 240), + child: ListView.builder( + itemExtent: 36, + itemCount: categoryRows.length + 4, + itemBuilder: (context, index) { + if (index < 4) { + final destination = [ + NotesDestination.all, + NotesDestination.favorites, + NotesDestination.uncategorized, + NotesDestination.recovery, + ][index]; + return ListTile( + dense: true, + minTileHeight: 36, + minVerticalPadding: 0, + selected: _navigation.destination == destination, + title: Text(destinationLabel(destination)), + trailing: Text('${_navigation.counts[destination] ?? 0}'), + onTap: () => setState(() { + _navigation.destination = destination; + _refreshNavigation(); + }), + ); + } + final category = categoryRows[index - 4]; + return ListTile( + dense: true, + minTileHeight: 36, + minVerticalPadding: 0, + contentPadding: EdgeInsets.only( + left: 8 + 12.0 * (category.split('/').length - 1), + right: 12, ), - child: DropdownButton( - isExpanded: true, - value: categories.contains(_category) ? _category : null, - hint: Text(context.l10n.syntaxReferenceCategory), - items: [ - DropdownMenuItem( - value: '', - child: Text(context.l10n.syntaxReferenceCategory), - ), - for (final category in sortedCategories) - DropdownMenuItem(value: category, child: Text(category)), - ], - onChanged: (value) => - setState(() => _category = value == '' ? null : value), + selected: + _navigation.destination == NotesDestination.category && + _navigation.category == category, + leading: IconButton( + constraints: const BoxConstraints.tightFor( + width: 24, + height: 24, + ), + padding: EdgeInsets.zero, + tooltip: category, + icon: Icon( + _navigation.collapsed.contains(category) + ? BusyMarkGlyphs.collapsedTreeArrowFor( + Directionality.of(context), + ) + : BusyMarkGlyphs.downArrow, + ), + onPressed: () => setState(() { + if (!_navigation.collapsed.remove(category)) { + _navigation.collapsed.add(category); + } + }), + ), + title: Text( + category.split('/').last, + maxLines: 1, + overflow: TextOverflow.ellipsis, ), + trailing: Text('${_navigation.categoryCounts[category]}'), + onTap: () => setState(() { + _navigation.destination = NotesDestination.category; + _navigation.category = category; + _refreshNavigation(); + }), + ); + }, + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: DropdownButton( + isExpanded: true, + value: _navigation.sort, + items: [ + DropdownMenuItem( + value: NotesSort.recent, + child: Text(context.l10n.notesRecentlyEdited), + ), + DropdownMenuItem( + value: NotesSort.oldest, + child: Text(context.l10n.notesOldestEdited), ), - Expanded( - child: visible.isEmpty - ? Center(child: Text(context.l10n.noResults)) - : ListView.builder( - itemCount: visible.length, - itemBuilder: (context, index) { - final note = visible[index]; - return ListTile( + DropdownMenuItem( + value: NotesSort.titleAscending, + child: Text(context.l10n.notesTitleAscending), + ), + DropdownMenuItem( + value: NotesSort.titleDescending, + child: Text(context.l10n.notesTitleDescending), + ), + ], + onChanged: (v) => setState(() { + _navigation.sort = v!; + _refreshNavigation(); + }), + ), + ), + if (_navigation.selected.isNotEmpty && + _navigation.destination != NotesDestination.recovery) + TextButton( + onPressed: () => unawaited( + showNotesBatch( + context, + ref, + _navigation.visible + .where((n) => _navigation.selected.contains(n.localId)) + .map((n) => n.localId) + .toList(), + ), + ), + child: Text( + '${context.l10n.notesSelected} (${_navigation.selected.length})', + ), + ), + if (_navigation.destination == NotesDestination.recovery) + Padding( + padding: const EdgeInsets.all(8), + child: Text( + context.l10n.notesRecoveryHelp, + style: Theme.of(context).textTheme.bodySmall, + ), + ), + Expanded( + child: Focus( + focusNode: _listFocus, + onKeyEvent: _listKey, + child: visible.isEmpty + ? Center(child: Text(context.l10n.noResults)) + : ListView.builder( + controller: _listScroll, + itemExtent: 100, + itemCount: visible.length, + itemBuilder: (context, index) { + final note = visible[index]; + final retained = offline?.statuses[note.localId]; + return GestureDetector( + onSecondaryTapDown: (details) => unawaited( + _contextMenu(note, details.globalPosition), + ), + child: ListTile( key: ValueKey('nextcloud-note-${note.localId}'), dense: true, - selected: active == note.localId, - title: Text(note.title), + selected: _navigation.selected.contains(note.localId), + shape: _navigation.focused == note.localId + ? Border( + left: BorderSide( + color: Theme.of( + context, + ).colorScheme.primary, + width: 2, + ), + ) + : null, + leading: Checkbox( + value: _navigation.selected.contains(note.localId), + semanticLabel: note.title, + onChanged: (_) => setState( + () => _navigation.select( + note.localId, + toggle: true, + ), + ), + ), + title: Text( + note.title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), subtitle: Text( [ note.category, - nextcloudSyncLabel(context, note.syncState), + MaterialLocalizations.of(context).formatShortDate( + DateTime.fromMicrosecondsSinceEpoch( + note.activityMicros, + ), + ), + if (_metadataNeedsReview(note)) + context.l10n.gitConflicts + else if (note.syncState != NoteSyncState.synced) + nextcloudSyncLabel(context, note.syncState), + if (note.content.contains('busymark-attachment:')) + context.l10n.nextcloudPendingUpload, + if (retained != null) + '${retained.available ? context.l10n.notesAvailableOffline : context.l10n.notesIncomplete} ${retained.availableCount}/${retained.requiredCount}', ].where((s) => s.isNotEmpty).join(' · '), + maxLines: 3, + overflow: TextOverflow.ellipsis, ), - leading: Icon( - note.favorite - ? YaruIcons.star_filled - : BusyMarkGlyphs.markdownFile, - ), - onTap: () => unawaited( - ref - .read(workspaceControllerProvider.notifier) - .openNextcloudNote(note.localId), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (active == note.localId) + Icon( + BusyMarkGlyphs.markdownFile, + size: 14, + semanticLabel: context.l10n.open, + ), + IconButton( + tooltip: note.favorite + ? context.l10n.notesRemoveFavorite + : context.l10n.nextcloudFavorites, + icon: Icon( + note.favorite + ? YaruIcons.star_filled + : YaruIcons.star, + size: 16, + ), + onPressed: () => unawaited( + ref + .read( + workspaceControllerProvider.notifier, + ) + .updateNextcloudNoteMetadata( + note.localId, + snapshot: repository.metadataSnapshot( + note.localId, + ), + favorite: !note.favorite, + ), + ), + ), + IconButton( + tooltip: context.l10n.mainMenu, + icon: const Icon( + BusyMarkGlyphs.menuVertical, + size: 16, + ), + onPressed: () => + unawaited(_contextMenu(note, null)), + ), + ], ), + onTap: () { + _listFocus.requestFocus(); + final keyboard = HardwareKeyboard.instance; + setState( + () => _navigation.select( + note.localId, + toggle: keyboard.isControlPressed, + range: keyboard.isShiftPressed, + ), + ); + if (!keyboard.isControlPressed && + !keyboard.isShiftPressed) { + unawaited( + isNotesRecovery(note) + ? showNotesRecovery(context, ref, note) + : ref + .read( + workspaceControllerProvider + .notifier, + ) + .openNextcloudNote(note.localId), + ); + } + }, onLongPress: () => unawaited(_editNote(note)), - trailing: IconButton( - tooltip: context.l10n.rename, - icon: const Icon(BusyMarkGlyphs.menuVertical), - onPressed: () => unawaited(_editNote(note)), - ), - ); - }, - ), - ), - ], + ), + ); + }, + ), + ), + ), + ], + ); + } + + Future _contextMenu(NextcloudNote note, Offset? position) async { + final targets = _navigation.contextTargets(note.localId); + final repository = await ref.read(nextcloudNotesRepositoryProvider.future); + final reviewed = [ + for (final id in targets) + if (repository.noteById(id) != null) repository.metadataSnapshot(id), + ]; + if (!mounted) return; + final overlay = Overlay.of(context).context.findRenderObject() as RenderBox; + final point = + position ?? + overlay.localToGlobal( + Offset(overlay.size.width / 2, overlay.size.height / 2), ); - }, + final action = await showMenu( + context: context, + position: RelativeRect.fromRect( + Rect.fromLTWH(point.dx, point.dy, 1, 1), + Offset.zero & overlay.size, + ), + items: [ + PopupMenuItem( + enabled: false, + child: Text( + targets.length > 1 + ? '${context.l10n.notesSelected} (${targets.length})' + : note.title, + ), + ), + if (!isNotesRecovery(note)) ...[ + PopupMenuItem( + value: 'favorite', + child: Text(context.l10n.nextcloudFavorites), + ), + PopupMenuItem( + value: 'unfavorite', + child: Text(context.l10n.notesRemoveFavorite), + ), + PopupMenuItem(value: 'move', child: Text(context.l10n.notesMove)), + ], + if (targets.length == 1) ...[ + PopupMenuItem(value: 'properties', child: Text(context.l10n.rename)), + PopupMenuItem( + value: 'offline', + child: Text(context.l10n.notesOffline), + ), + if (isNotesRecovery(note)) + PopupMenuItem( + value: 'recovery', + child: Text(context.l10n.notesRecovery), + ), + ], + ], ); + if (!mounted || action == null) return; + if ({'favorite', 'unfavorite', 'move'}.contains(action)) { + await showNotesBatch( + context, + ref, + targets, + initialAction: action, + reviewedTargets: reviewed, + ); + } + if (action == 'properties') await _editNote(note); + if (!mounted) return; + if (action == 'offline') await showNotesOffline(context, ref, note); + if (!mounted) return; + if (action == 'recovery') await showNotesRecovery(context, ref, note); } Future _editNote(NextcloudNote note) async { + final repository = await ref.read(nextcloudNotesRepositoryProvider.future); + if (!mounted || repository.noteById(note.localId) == null) return; + final snapshot = repository.metadataSnapshot(note.localId); + note = snapshot.note; var title = note.title; var category = note.category; var favorite = note.favorite; @@ -303,9 +801,12 @@ class _NextcloudNotesSidebarState extends ConsumerState { } else if (action == 'save') { await controller.updateNextcloudNoteMetadata( note.localId, - title: note.readonly ? null : title, - category: note.readonly ? null : category, - favorite: favorite, + snapshot: snapshot, + title: note.readonly || title == snapshot.note.title ? null : title, + category: note.readonly || category == snapshot.note.category + ? null + : category, + favorite: favorite == snapshot.note.favorite ? null : favorite, ); } else if (action == 'delete') { final confirmed = await showBusyMarkModalDialog( @@ -342,10 +843,8 @@ class NextcloudNoteStatus extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { ref.watch(nextcloudNotesChangesProvider); - final note = ref - .watch(nextcloudNotesRepositoryProvider) - .value - ?.noteById(localId); + final repository = ref.watch(nextcloudNotesRepositoryProvider).value; + final note = repository?.noteById(localId); if (note == null) return const SizedBox.shrink(); return Padding( padding: const EdgeInsets.symmetric( @@ -361,6 +860,14 @@ class NextcloudNoteStatus extends ConsumerWidget { child: Text( unsaved ? context.l10n.closeUnsavedChangesTitle + : _metadataNeedsReview(note) + ? context.l10n.gitConflicts + : repository!.isSynchronizing(note.accountId) + ? context.l10n.nextcloudSyncing + : notesAttachmentReferences(note.content).any( + (r) => r.reference.startsWith('busymark-attachment:'), + ) + ? context.l10n.nextcloudPendingUpload : nextcloudSyncLabel(context, note.syncState), ), ), @@ -373,9 +880,11 @@ class NextcloudNoteStatus extends ConsumerWidget { addNextcloudFileAttachment(context, ref, note.localId), ), ), - if (note.syncState == NoteSyncState.conflict || + if (note.metadataConflict != null || + note.syncState == NoteSyncState.conflict || note.syncState == NoteSyncState.deletedRemotely || note.syncState == NoteSyncState.creationUncertain || + note.syncState == NoteSyncState.recoveryRequired || ((note.syncState == NoteSyncState.forbidden || note.syncState == NoteSyncState.unavailable) && note.hasPendingChanges)) @@ -394,8 +903,25 @@ class NextcloudNoteStatus extends ConsumerWidget { ), child: Text(context.l10n.visualizationRetry), ), + if (note.syncState == NoteSyncState.rejected || + note.syncState == NoteSyncState.storageFull) + BusyMarkPushButton.standard( + onPressed: () => unawaited( + ref + .read(workspaceControllerProvider.notifier) + .retryRejectedNextcloudNote(localId), + ), + child: Text(context.l10n.visualizationRetry), + ), ], ), + if (repository?.accountById(note.accountId)?.lastServerCheck + case final checked?) + Text( + context.l10n.nextcloudServerCheck( + '${MaterialLocalizations.of(context).formatShortDate(checked.toLocal())} ${MaterialLocalizations.of(context).formatTimeOfDay(TimeOfDay.fromDateTime(checked.toLocal()))}', + ), + ), if (note.errorMessage != null) Text( note.errorMessage!, @@ -495,8 +1021,9 @@ Future showNextcloudConflict( final current = repository.noteById(note.localId); if (current == null || !context.mounted) return; note = current; + final localMetadata = note.metadataConflict != null; final remote = note.remote; - final creationCandidates = note.serverId == null + final creationCandidates = note.serverId == null && !localMetadata ? repository.uncertainCreationCandidates(note.localId) : const []; var creationCandidateServerId = remote?.id; @@ -509,17 +1036,23 @@ Future showNextcloudConflict( .firstOrNull; } - final canOverwriteRemote = - note.serverId != null && - remote != null && - !remote.readonly && - !remote.error; + final canOverwriteRemote = localMetadata + ? !note.readonly && !note.error + : note.serverId != null && + remote != null && + !remote.readonly && + !remote.error; final deletedRemotely = note.syncState == NoteSyncState.deletedRemotely && remote == null; - final merge = remote == null ? null : NotesConflictMerge(note, remote); + final merge = localMetadata + ? NotesConflictMerge.metadata(note) + : remote == null + ? null + : NotesConflictMerge(note, remote); final canMergeRemote = canOverwriteRemote || - (note.serverId != null && + (!localMetadata && + note.serverId != null && remote != null && remote.readonly && !remote.error && @@ -540,15 +1073,21 @@ Future showNextcloudConflict( context, builder: (context) => StatefulBuilder( builder: (context, setDialogState) { - final displayedRemote = note.serverId == null + final displayedRemote = localMetadata + ? null + : note.serverId == null ? selectedCreationRemote() : remote; final comparison = compareSource( SourceComparisonInput( id: 'remote:${note.localId}', version: displayedRemote?.modified ?? 0, - label: context.l10n.nextcloudTakeRemote, - source: displayedRemote?.content ?? '', + label: localMetadata + ? context.l10n.nextcloudPreviousLocalEdit + : context.l10n.nextcloudTakeRemote, + source: localMetadata + ? note.content + : displayedRemote?.content ?? '', ), SourceComparisonInput( id: note.localId, @@ -566,12 +1105,22 @@ Future showNextcloudConflict( onPressed: () => Navigator.pop(context), ), BusyMarkDialogButton( - label: note.serverId == null + label: note.serverId == null && !localMetadata ? context.l10n.nextcloudCreateSeparate : context.l10n.nextcloudNewNote, onPressed: () => Navigator.pop(context, NoteConflictResolution.saveAsNew), ), + if (localMetadata) + BusyMarkDialogButton( + label: context.l10n.nextcloudUsePreviousEdit, + onPressed: canOverwriteRemote + ? () => Navigator.pop( + context, + NoteConflictResolution.takeRemote, + ) + : null, + ), if (displayedRemote != null && !displayedRemote.error && (note.serverId != null || note.creationAttempt != null)) @@ -681,7 +1230,7 @@ Future showNextcloudConflict( DropdownMenuItem( value: NotesMergeChoice.remote, child: Text( - '${context.l10n.nextcloudTakeRemote}: ${entry.value.remote}', + '${localMetadata ? context.l10n.nextcloudPreviousLocalEdit : context.l10n.nextcloudTakeRemote}: ${entry.value.remote}', ), ), ], @@ -725,7 +1274,9 @@ Future showNextcloudConflict( ); if (confirmed != true) return; } - if (note.serverId == null && resolution == NoteConflictResolution.saveAsNew) { + if (note.serverId == null && + !localMetadata && + resolution == NoteConflictResolution.saveAsNew) { if (!context.mounted) return; final confirmed = await showBusyMarkModalDialog( context, diff --git a/lib/src/nextcloud_notes/presentation/notes_workspace_ui.dart b/lib/src/nextcloud_notes/presentation/notes_workspace_ui.dart new file mode 100644 index 00000000..83053aea --- /dev/null +++ b/lib/src/nextcloud_notes/presentation/notes_workspace_ui.dart @@ -0,0 +1,1143 @@ +import 'dart:async'; + +import 'package:file_selector/file_selector.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../app/app_settings.dart'; +import '../../app/busymark_design.dart'; +import '../../app/busymark_dialogs.dart'; +import '../../app/busymark_glyphs.dart'; +import '../../app/localization.dart'; +import '../../local_history/local_history_controller.dart'; +import '../../local_history/local_history_models.dart'; +import '../../workspace/workspace_controller.dart'; +import '../application/nextcloud_connection.dart'; +import '../application/notes_navigation.dart'; +import '../application/notes_repository.dart'; +import '../application/notes_offline_controller.dart'; +import '../application/notes_search_controller.dart'; +import '../application/notes_transfer_service.dart'; +import '../domain/notes_models.dart'; +import '../domain/notes_search.dart'; + +final notesOfflineProvider = FutureProvider.autoDispose + .family((ref, accountId) async { + final repository = await ref.watch( + nextcloudNotesRepositoryProvider.future, + ); + final controller = NotesOfflineController(repository, accountId); + ref.onDispose(() => unawaited(controller.dispose())); + await controller.initialize(); + return controller; + }); +final notesOfflineChangesProvider = StreamProvider.autoDispose + .family((ref, accountId) async* { + final controller = await ref.watch( + notesOfflineProvider(accountId).future, + ); + var generation = 0; + await for (final _ in controller.changes) { + yield ++generation; + } + }); + +class NotesMatchNavigation { + const NotesMatchNavigation( + this.localId, + this.start, + this.end, + this.digest, + this.request, + ); + final String localId, digest; + final int start, end, request; +} + +final notesMatchNavigationProvider = + NotifierProvider( + NotesMatchNavigationController.new, + ); + +class NotesMatchNavigationController extends Notifier { + @override + NotesMatchNavigation? build() => null; + void navigate(String id, int start, int end, String digest) { + state = NotesMatchNavigation( + id, + start, + end, + digest, + (state?.request ?? 0) + 1, + ); + } +} + +Future showNotesSearch( + BuildContext context, + WidgetRef ref, { + String query = '', + String? account, + bool recovery = false, +}) async { + final accountId = + account ?? + ref.read(workspaceControllerProvider).workspace?.nextcloudAccountId; + if (accountId == null) return; + final focus = FocusManager.instance.primaryFocus; + final choice = + await showBusyMarkModalDialog< + ({NotesSearchHit hit, String query, bool wholeWord, bool source}) + >( + context, + builder: (_) => NotesSearchDialog( + accountId: accountId, + initialQuery: query, + recovery: recovery, + ), + ); + if (!context.mounted) return; + if (choice == null) { + if (focus?.context?.mounted == true) focus!.requestFocus(); + return; + } + if (ref.read(workspaceControllerProvider).workspace?.nextcloudAccountId != + accountId) { + return; + } + if (recovery) { + final repository = await ref.read(nextcloudNotesRepositoryProvider.future); + final note = repository.noteById(choice.hit.localId); + if (context.mounted && + note != null && + note.accountId == accountId && + isNotesRecovery(note)) { + await showNotesRecovery(context, ref, note); + } + return; + } + final controller = ref.read(workspaceControllerProvider.notifier); + if (!await controller.openNextcloudNote(choice.hit.localId) || + !context.mounted) { + return; + } + final buffer = ref.read(workspaceControllerProvider).activeBuffer; + if (buffer?.remoteNote?.localId != choice.hit.localId) return; + final repository = await ref.read(nextcloudNotesRepositoryProvider.future); + final note = repository.noteById(choice.hit.localId); + if (note == null) return; + var hit = choice.hit; + // Even a revision collision cannot apply offsets to a different document. + if (buffer!.revision != hit.revision || + notesSearchDigest(buffer.text) != hit.digest) { + final refreshed = await relocateNotesSearchHit( + NotesSearchQuery(choice.query, wholeWord: choice.wholeWord), + NotesSearchOverlay( + note.localId, + buffer.revision, + note.title, + note.category, + buffer.text, + ), + hit.start ?? 0, + ); + if (refreshed == null || !context.mounted) return; + final current = ref.read(workspaceControllerProvider).activeBuffer; + if (current?.remoteNote?.localId != note.localId || + current!.revision != refreshed.revision || + notesSearchDigest(current.text) != refreshed.digest) { + return; + } + hit = refreshed; + } + if (hit.start == null || hit.end == null) return; + if (choice.source) { + await ref + .read(appSettingsControllerProvider.notifier) + .setDocumentViewMode(DocumentViewModePreference.source); + } + ref + .read(notesMatchNavigationProvider.notifier) + .navigate(hit.localId, hit.start!, hit.end!, hit.digest); +} + +class NotesSearchDialog extends ConsumerStatefulWidget { + const NotesSearchDialog({ + super.key, + required this.accountId, + this.initialQuery = '', + this.recovery = false, + }); + final String accountId, initialQuery; + final bool recovery; + @override + ConsumerState createState() => _NotesSearchDialogState(); +} + +class _NotesSearchDialogState extends ConsumerState { + NotesSearchController? _controller; + StreamSubscription? _results; + StreamSubscription? _repositoryChanges; + Timer? _debounce; + late final _text = TextEditingController(text: widget.initialQuery); + late final _focus = FocusNode(onKeyEvent: _key); + final _scroll = ScrollController(); + NotesSearchState _state = const NotesSearchState(loading: true); + bool _wholeWord = false; + int _selected = 0, _limit = 80; + @override + void initState() { + super.initState(); + unawaited(_initialize()); + } + + Future _initialize() async { + final repository = await ref.read(nextcloudNotesRepositoryProvider.future); + if (!mounted) return; + _controller = NotesSearchController( + repository.store, + widget.accountId, + recovery: widget.recovery, + ); + _results = _controller!.changes.listen((state) { + if (mounted) { + setState(() { + _state = state; + _selected = 0; + }); + } + }); + _repositoryChanges = repository.changes.listen((_) => _schedule()); + _search(); + } + + void _schedule() { + _controller?.cancel(); + _debounce?.cancel(); + _debounce = Timer(const Duration(milliseconds: 150), _search); + } + + void _search() { + final repository = ref.read(nextcloudNotesRepositoryProvider).value; + if (repository == null || _controller == null) return; + final overlays = [ + for (final buffer + in ref.read(workspaceControllerProvider).documentBuffers) + if (!widget.recovery && + buffer.isDirty && + buffer.remoteNote?.accountId == widget.accountId && + repository.noteById(buffer.remoteNote!.localId) != null) + NotesSearchOverlay( + buffer.remoteNote!.localId, + buffer.revision, + repository.noteById(buffer.remoteNote!.localId)!.title, + repository.noteById(buffer.remoteNote!.localId)!.category, + buffer.text, + ), + ]; + unawaited( + _controller!.search( + _text.text, + wholeWord: _wholeWord, + limit: _limit, + overlays: overlays, + ), + ); + } + + KeyEventResult _key(FocusNode node, KeyEvent event) { + if (event is! KeyDownEvent) return KeyEventResult.ignored; + if (event.logicalKey == LogicalKeyboardKey.escape) { + Navigator.pop(context); + return KeyEventResult.handled; + } + if (event.logicalKey == LogicalKeyboardKey.enter) { + _open(); + return KeyEventResult.handled; + } + if (event.logicalKey == LogicalKeyboardKey.arrowUp || + event.logicalKey == LogicalKeyboardKey.arrowDown) { + if (_state.hits.isNotEmpty) { + setState( + () => _selected = + (_selected + + (event.logicalKey == LogicalKeyboardKey.arrowDown + ? 1 + : -1)) + .clamp(0, _state.hits.length - 1), + ); + if (_scroll.hasClients) { + _scroll.animateTo( + (_selected * 90.0).clamp(0, _scroll.position.maxScrollExtent), + duration: const Duration(milliseconds: 100), + curve: Curves.easeOut, + ); + } + } + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + } + + void _open({bool source = false, int? index}) { + if (_state.loading || _state.hits.isEmpty) return; + Navigator.pop(context, ( + hit: _state.hits[index ?? _selected], + query: _text.text, + wholeWord: _wholeWord, + source: source, + )); + } + + @override + void dispose() { + _debounce?.cancel(); + unawaited(_results?.cancel()); + unawaited(_repositoryChanges?.cancel()); + unawaited(_controller?.dispose()); + _text.dispose(); + _focus.dispose(); + _scroll.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + ref.listen( + workspaceControllerProvider.select((s) => s.documentBuffers), + (a, b) => _schedule(), + ); + return BusyMarkDialogShell( + title: widget.recovery + ? '${context.l10n.notesRecovery} · ${context.l10n.search}' + : context.l10n.search, + maxWidth: 760, + children: [ + TextField( + autofocus: true, + focusNode: _focus, + controller: _text, + decoration: InputDecoration(labelText: context.l10n.search), + onChanged: (_) { + _limit = 80; + _schedule(); + }, + ), + if (widget.recovery) Text(context.l10n.notesRecoveryHelp), + Text( + context.l10n.notesSearchHelp, + style: Theme.of(context).textTheme.bodySmall, + ), + CheckboxListTile( + dense: true, + title: Text(context.l10n.sourceSearchWholeWord), + value: _wholeWord, + onChanged: (v) => setState(() { + _wholeWord = v ?? false; + _schedule(); + }), + ), + if (_state.loading) const LinearProgressIndicator(), + if (_state.indexing) Text(context.l10n.notesIndexing), + if (_state.error != null) + Text('${context.l10n.warning}: ${_state.error}'), + SizedBox( + height: 360, + child: _state.hits.isEmpty + ? Center( + child: Text( + _state.loading + ? context.l10n.notesIndexing + : context.l10n.noResults, + ), + ) + : ListView.builder( + key: const ValueKey('notes-search-results'), + controller: _scroll, + itemCount: _state.hits.length, + itemBuilder: (context, index) { + final hit = _state.hits[index]; + final from = + hit.snippetMatchStart ?? + (hit.start == null + ? 0 + : (hit.start! - hit.snippetStart).clamp( + 0, + hit.snippet.length, + )); + final to = + hit.snippetMatchEnd ?? + (hit.end == null + ? 0 + : (hit.end! - hit.snippetStart).clamp( + from, + hit.snippet.length, + )); + return ListTile( + key: ValueKey(( + 'notes-search-hit', + hit.localId, + hit.start, + hit.end, + )), + selected: index == _selected, + title: Text( + hit.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (hit.category.isNotEmpty) + Text( + hit.category, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + Text.rich( + TextSpan( + children: [ + TextSpan( + text: hit.snippet + .substring(0, from) + .replaceAll(RegExp(r'[\r\n\t]'), ' '), + ), + TextSpan( + text: hit.snippet + .substring(from, to) + .replaceAll(RegExp(r'[\r\n\t]'), ' '), + style: TextStyle( + backgroundColor: Theme.of( + context, + ).colorScheme.primaryContainer, + fontWeight: FontWeight.bold, + ), + ), + TextSpan( + text: hit.snippet + .substring(to) + .replaceAll(RegExp(r'[\r\n\t]'), ' '), + ), + ], + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ), + trailing: widget.recovery || hit.start == null + ? null + : IconButton( + tooltip: context.l10n.notesSourceLocation, + icon: const Icon(BusyMarkGlyphs.code), + onPressed: () => + _open(source: true, index: index), + ), + onTap: () => _open(index: index), + ); + }, + ), + ), + if (_state.truncated) + TextButton( + onPressed: () { + _limit += 80; + _search(); + }, + child: Text(context.l10n.workspaceSearchShowMore), + ), + ], + ); + } +} + +Future showNotesBatch( + BuildContext context, + WidgetRef ref, + List ids, { + String? initialAction, + List? reviewedTargets, +}) async { + final repository = await ref.read(nextcloudNotesRepositoryProvider.future); + final reviewed = + reviewedTargets ?? + [ + for (final id in ids) + if (repository.noteById(id) != null) repository.metadataSnapshot(id), + ]; + if (!context.mounted || reviewed.isEmpty) return; + var action = initialAction ?? 'favorite'; + var category = ''; + final confirmed = await showBusyMarkModalDialog( + context, + builder: (context) => StatefulBuilder( + builder: (context, setDialogState) => BusyMarkDialogShell( + title: '${context.l10n.notesSelected} (${reviewed.length})', + actions: [ + BusyMarkDialogButton( + label: context.l10n.cancel, + onPressed: () => Navigator.pop(context, false), + ), + BusyMarkDialogButton( + label: context.l10n.save, + onPressed: () => Navigator.pop(context, true), + ), + ], + children: [ + SizedBox( + height: 140, + child: ListView( + children: [ + for (final snapshot in reviewed) + ListTile( + dense: true, + title: Text(snapshot.note.title), + subtitle: Text(snapshot.note.category), + ), + ], + ), + ), + DropdownButton( + isExpanded: true, + value: action, + items: [ + DropdownMenuItem( + value: 'favorite', + child: Text(context.l10n.nextcloudFavorites), + ), + DropdownMenuItem( + value: 'unfavorite', + child: Text(context.l10n.notesRemoveFavorite), + ), + DropdownMenuItem( + value: 'move', + child: Text(context.l10n.notesMove), + ), + ], + onChanged: (v) => setDialogState(() => action = v!), + ), + if (action == 'move') + TextField( + decoration: InputDecoration( + labelText: context.l10n.syntaxReferenceCategory, + ), + onChanged: (v) => category = v, + ), + ], + ), + ), + ); + if (confirmed != true || !context.mounted) return; + final outcomes = await ref + .read(workspaceControllerProvider.notifier) + .batchNextcloudMetadata( + reviewed, + category: action == 'move' ? category : null, + favorite: action == 'move' ? null : action == 'favorite', + ); + if (!context.mounted) return; + await showBusyMarkModalDialog( + context, + builder: (context) => BusyMarkDialogShell( + title: context.l10n.notesSelected, + children: [ + for (final outcome in outcomes) + ListTile( + title: Text(outcome.title), + subtitle: Text( + [ + switch (outcome.status) { + NotesBatchStatus.changed => + context.l10n.nextcloudSavedLocally, + NotesBatchStatus.skipped => context.l10n.notesSkipped, + NotesBatchStatus.conflicted => context.l10n.gitConflicts, + NotesBatchStatus.failed => context.l10n.notesFailed, + }, + if (outcome.detail != null) outcome.detail!, + ].join(' · '), + ), + ), + ], + ), + ); +} + +Future showNotesOffline( + BuildContext context, + WidgetRef ref, + NextcloudNote note, +) async { + final controller = await ref.read( + notesOfflineProvider(note.accountId).future, + ); + if (!context.mounted) return; + await showBusyMarkModalDialog( + context, + builder: (context) => StreamBuilder( + stream: controller.changes, + builder: (context, _) => FutureBuilder( + future: controller.inspect(note), + builder: (context, snapshot) { + final status = snapshot.data; + return BusyMarkDialogShell( + title: context.l10n.notesOffline, + actions: [ + BusyMarkDialogButton( + label: context.l10n.visualizationRetry, + onPressed: () => unawaited(controller.retry(note.localId)), + ), + BusyMarkDialogButton( + label: context.l10n.cancel, + onPressed: () => unawaited(controller.cancelNote(note.localId)), + ), + BusyMarkDialogButton( + label: controller.individuallyRetained(note.localId) + ? context.l10n.notesOfflineRemove + : context.l10n.notesOffline, + onPressed: () => unawaited( + controller.setRequirement( + 'note', + note.localId, + required: !controller.individuallyRetained(note.localId), + ), + ), + ), + ], + children: [ + Text(note.title), + if (status == null) + const LinearProgressIndicator() + else ...[ + Text(context.l10n.notesTextAvailable), + Text( + '${status.available ? context.l10n.notesAvailableOffline : context.l10n.notesIncomplete} · ${status.availableCount}/${status.requiredCount}', + ), + if (status.running) const LinearProgressIndicator(), + for (final reference in status.missing) Text(reference), + for (final reference in status.external) + Text('${context.l10n.notesIncomplete}: $reference'), + for (final failure in controller.failures.entries.where( + (e) => e.key.startsWith('${note.localId}:'), + )) + Text(failure.value), + ], + ], + ); + }, + ), + ), + ); +} + +Future showNotesRecovery( + BuildContext context, + WidgetRef ref, + NextcloudNote note, +) async { + await ref.read(localHistoryControllerProvider.notifier).refresh(); + if (!context.mounted) return; + await showBusyMarkModalDialog( + context, + builder: (_) => NotesRecoveryDialog(note: note), + ); +} + +class NotesRecoveryDialog extends ConsumerStatefulWidget { + const NotesRecoveryDialog({super.key, required this.note}); + final NextcloudNote note; + @override + ConsumerState createState() => + _NotesRecoveryDialogState(); +} + +class _NotesRecoveryDialogState extends ConsumerState { + LocalHistoryDocument? _document; + LocalHistoryRevision? _revision; + late String _source = widget.note.content; + NotesOfflineStatus? _availability; + int _generation = 0; + @override + void initState() { + super.initState(); + unawaited(_inspect()); + } + + Future _inspect() async { + final generation = ++_generation; + final repository = await ref.read(nextcloudNotesRepositoryProvider.future); + final availability = await _inspectRecoveryAvailability( + repository, + widget.note, + source: _source, + ); + if (mounted && generation == _generation) { + setState(() => _availability = availability); + } + } + + @override + Widget build(BuildContext context) { + final history = ref.watch(localHistoryControllerProvider); + _document ??= history.snapshot.documents + .where( + (d) => + d.remoteNote?.accountId == widget.note.accountId && + d.remoteNote?.localId == widget.note.localId, + ) + .firstOrNull; + final summaries = _document == null + ? [] + : history.snapshot.revisionsFor(_document!.id); + return BusyMarkDialogShell( + title: context.l10n.notesRecovery, + maxWidth: 760, + actions: [ + BusyMarkDialogButton( + label: + '${context.l10n.notesRecovery}: ${context.l10n.nextcloudNewNote}', + onPressed: _availability == null || _availability!.missing.isNotEmpty + ? null + : () async { + final controller = ref.read( + workspaceControllerProvider.notifier, + ); + final success = _revision == null || _document == null + ? await controller.recoverDeletedNextcloudNote( + widget.note.localId, + content: _source, + ) + : await controller.recoverNextcloudHistoryRevision( + document: _document!, + revision: _revision!, + ); + if (context.mounted && success) Navigator.pop(context); + }, + ), + ], + children: [ + Text(widget.note.title), + Text(context.l10n.notesRecoveryHelp), + if (summaries.isEmpty) Text(context.l10n.notesNoHistory), + if (!ref + .read(localHistoryControllerProvider.notifier) + .policy + .recordingEnabled) + Text(context.l10n.localHistoryWarningRecordingDisabled), + DropdownButton( + isExpanded: true, + value: _revision?.summary.id ?? '', + items: [ + DropdownMenuItem( + value: '', + child: Text(context.l10n.notesTextAvailable), + ), + for (final summary in summaries) + DropdownMenuItem( + value: summary.id, + child: Text(summary.capturedAt.toLocal().toString()), + ), + ], + onChanged: (id) async { + if (id == '') { + setState(() { + _revision = null; + _source = widget.note.content; + _availability = null; + }); + await _inspect(); + return; + } + if (_document == null) return; + final controller = ref.read( + localHistoryControllerProvider.notifier, + ); + controller.selectDocument(_document!.id); + await controller.selectRevision(id!); + final revision = ref + .read(localHistoryControllerProvider) + .selectedRevision; + if (mounted && revision != null) { + setState(() { + _revision = revision; + _source = revision.source; + _availability = null; + }); + await _inspect(); + } + }, + ), + if (_availability == null) + const LinearProgressIndicator() + else ...[ + Text( + '${context.l10n.notesAvailableOffline}: ${_availability!.availableCount}/${_availability!.requiredCount}', + ), + for (final item in _availability!.missing) + Text('${context.l10n.notesIncomplete}: $item'), + for (final item in _availability!.external) + Text('${context.l10n.notesIncomplete}: $item'), + ], + SizedBox( + height: 260, + child: SingleChildScrollView(child: SelectableText(_source)), + ), + ], + ); + } +} + +Future showNotesExport( + BuildContext context, + WidgetRef ref, + List ids, +) async { + final completedLabel = context.l10n.notesCompleted; + final incompleteLabel = context.l10n.notesIncomplete; + final parent = await getDirectoryPath( + confirmButtonText: context.l10n.notesExport, + ); + if (parent == null || !context.mounted) return; + List? snapshot; + Object? failure; + try { + snapshot = await ref + .read(workspaceControllerProvider.notifier) + .captureNextcloudExport(ids.toSet()); + if (snapshot == null) failure = incompleteLabel; + } on Object catch (error) { + failure = error; + } + if (!context.mounted) return; + if (failure != null || snapshot == null) { + await showBusyMarkModalDialog( + context, + builder: (context) => BusyMarkDialogShell( + title: context.l10n.warning, + children: [Text(context.l10n.workspaceErrorSaveFailed('$failure'))], + ), + ); + return; + } + final captured = snapshot; + final repository = await ref.read(nextcloudNotesRepositoryProvider.future); + if (!context.mounted) return; + await showBusyMarkModalDialog( + context, + barrierDismissible: false, + builder: (_) => NotesTransferProgressDialog( + title: context.l10n.notesExport, + operation: (cancel, progress) async { + final result = await NotesTransferService(repository).exportSnapshot( + notes: captured, + destination: parent, + cancellation: cancel, + onProgress: progress, + ); + return [ + result.complete ? completedLabel : incompleteLabel, + '${result.count}', + result.path, + ...result.omissions, + ]; + }, + ), + ); +} + +Future showNotesImport( + BuildContext context, + WidgetRef ref, { + bool folder = true, +}) async { + final accountId = ref + .read(workspaceControllerProvider) + .workspace + ?.nextcloudAccountId; + if (accountId == null) return; + final savedLocally = context.l10n.nextcloudSavedLocally; + final alreadyImported = context.l10n.notesImportAlreadyImported; + final synced = context.l10n.nextcloudSynced; + final source = folder + ? await getDirectoryPath(confirmButtonText: context.l10n.notesImport) + : (await openFile( + acceptedTypeGroups: [ + XTypeGroup( + label: context.l10n.fileTypeMarkdown, + extensions: ['md', 'markdown'], + ), + ], + ))?.path; + if (source == null || !context.mounted) return; + final repository = await ref.read(nextcloudNotesRepositoryProvider.future); + final service = NotesTransferService(repository); + try { + var review = await service.review(source, accountId); + final pending = (await repository.store.importOperations( + accountId, + )).where((o) => o['root'] == review.root).toList(); + if (!context.mounted) return; + if (pending.isNotEmpty) { + final choice = await showBusyMarkModalDialog( + context, + builder: (_) => NotesImportOperationDialog(operations: pending), + ); + if (choice == null || !context.mounted) return; + if (choice.isNotEmpty) { + review = await service.resumeImport(choice, accountId); + } + if (!context.mounted) return; + } + final confirmed = await showBusyMarkModalDialog( + context, + builder: (_) => NotesImportReviewDialog(review: review), + ); + if (confirmed != true || !context.mounted) return; + await showBusyMarkModalDialog( + context, + barrierDismissible: false, + builder: (_) => NotesTransferProgressDialog( + title: context.l10n.notesImport, + operation: (cancel, progress) async { + final results = await service.importReviewed( + review, + accountId, + cancellation: cancel, + onProgress: progress, + ); + ref + .read(workspaceControllerProvider.notifier) + .synchronizeImportedNotes(accountId); + return [ + for (final result in results) + '${result.item}: ${result.error ?? [if (result.alreadyImported) alreadyImported, repository.noteById(result.noteId ?? '')?.hasPendingChanges == false ? synced : savedLocally].join(' · ')}', + ]; + }, + ), + ); + } on Object catch (error) { + if (context.mounted) { + await showBusyMarkModalDialog( + context, + builder: (context) => BusyMarkDialogShell( + title: context.l10n.warning, + children: [Text(error.toString())], + ), + ); + } + } +} + +/// Choosing a new import never reuses a prior source-item association. Resume +/// is explicit and selects a persisted reviewed operation within this account. +class NotesImportOperationDialog extends StatelessWidget { + const NotesImportOperationDialog({super.key, required this.operations}); + final List> operations; + @override + Widget build(BuildContext context) => BusyMarkDialogShell( + title: context.l10n.notesImport, + actions: [ + BusyMarkDialogButton( + label: context.l10n.cancel, + onPressed: () => Navigator.pop(context), + ), + ], + children: [ + ListTile( + title: Text(context.l10n.notesImportNew), + onTap: () => Navigator.pop(context, ''), + ), + for (final operation in operations) + ListTile( + title: Text(context.l10n.notesImportResume), + subtitle: Text( + '${operation['root']}\n${DateTime.fromMillisecondsSinceEpoch((operation['created_at'] as int) * 1000).toLocal()}', + ), + onTap: () => Navigator.pop(context, operation['id'] as String), + ), + ], + ); +} + +class NotesImportReviewDialog extends StatefulWidget { + const NotesImportReviewDialog({super.key, required this.review}); + final NotesImportReview review; + @override + State createState() => + _NotesImportReviewDialogState(); +} + +class _NotesImportReviewDialogState extends State { + @override + Widget build(BuildContext context) => BusyMarkDialogShell( + title: context.l10n.notesImport, + maxWidth: 780, + actions: [ + BusyMarkDialogButton( + label: context.l10n.cancel, + onPressed: () => Navigator.pop(context, false), + ), + BusyMarkDialogButton( + label: context.l10n.notesImport, + onPressed: widget.review.items.any((i) => i.selected) + ? () => Navigator.pop(context, true) + : null, + ), + ], + children: [ + for (final issue in widget.review.issues) Text(issue), + SizedBox( + height: 400, + child: ListView.builder( + itemCount: widget.review.items.length, + itemBuilder: (context, index) { + final item = widget.review.items[index]; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + CheckboxListTile( + value: item.selected, + onChanged: item.alreadyImported + ? null + : (v) => setState(() => item.selected = v ?? false), + title: Text(item.title), + subtitle: Text( + item.alreadyImported + ? context.l10n.notesImportAlreadyImported + : item.collision + ? context.l10n.notesImportDistinct + : item.path, + ), + ), + TextFormField( + initialValue: item.category, + readOnly: item.alreadyImported, + decoration: InputDecoration( + labelText: context.l10n.syntaxReferenceCategory, + ), + onChanged: (v) => item.category = v, + ), + Text('${context.l10n.notesAttachments}: ${item.media.length}'), + for (final reference in item.media.keys) + Text(reference, style: Theme.of(context).textTheme.bodySmall), + for (final issue in item.issues) + Text('${context.l10n.notesIncomplete}: $issue'), + const Divider(), + ], + ); + }, + ), + ), + ], + ); +} + +class NotesTransferProgressDialog extends StatefulWidget { + const NotesTransferProgressDialog({ + super.key, + required this.title, + required this.operation, + }); + final String title; + final Future> Function( + NotesTransferCancellation, + void Function(int, int), + ) + operation; + @override + State createState() => + _NotesTransferProgressDialogState(); +} + +class _NotesTransferProgressDialogState + extends State { + final _cancel = NotesTransferCancellation(); + int _completed = 0, _total = 0; + List? _result; + @override + void initState() { + super.initState(); + unawaited(_run()); + } + + Future _run() async { + List result; + try { + result = await widget.operation(_cancel, (a, b) { + if (mounted) { + setState(() { + _completed = a; + _total = b; + }); + } + }); + } on Object catch (error) { + result = [error.toString()]; + } + if (mounted) setState(() => _result = result); + } + + @override + void dispose() { + _cancel.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => PopScope( + canPop: _result != null, + child: BusyMarkDialogShell( + title: widget.title, + closable: _result != null, + actions: [ + BusyMarkDialogButton( + label: _result == null ? context.l10n.cancel : context.l10n.close, + onPressed: () { + if (_result == null) { + setState(_cancel.cancel); + } else { + Navigator.pop(context); + } + }, + ), + ], + children: [ + Text('$_completed/$_total'), + if (_result == null) + const LinearProgressIndicator() + else + SizedBox( + height: 300, + child: ListView( + children: [for (final line in _result!) Text(line)], + ), + ), + if (_cancel.cancelled) Text(context.l10n.notesIncomplete), + ], + ), + ); +} + +Future _inspectRecoveryAvailability( + NotesRepository repository, + NextcloudNote note, { + required String source, +}) async { + final dependencies = await repository.attachmentAvailability( + note.localId, + source: source, + ); + return NotesOfflineStatus( + revision: note.revision, + textAvailable: true, + requiredCount: dependencies.required.length, + availableCount: dependencies.available.length, + missing: List.from( + dependencies.required.where( + (String r) => !dependencies.available.contains(r), + ), + ), + external: List.from(dependencies.external), + ); +} diff --git a/lib/src/references/markdown_move_service.dart b/lib/src/references/markdown_move_service.dart new file mode 100644 index 00000000..b88564ce --- /dev/null +++ b/lib/src/references/markdown_move_service.dart @@ -0,0 +1,745 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:isolate'; + +import 'package:path/path.dart' as p; +import 'package:crypto/crypto.dart'; +import 'package:uuid/uuid.dart'; + +import '../core/anchored_path_guard.dart'; +import '../core/atomic_file_writer.dart'; +import '../core/linux_file_identity.dart'; +import '../core/linux_atomic_file_api.dart'; +import '../workspace/workspace_model.dart'; +import '../workspace/workspace_service.dart'; +import '../workspace/text_format_metadata.dart'; +import 'markdown_references.dart'; + +class MarkdownDestinationEdit { + const MarkdownDestinationEdit(this.start, this.end, this.before, this.after); + final int start, end; + final String before, after; +} + +class MarkdownMoveFile { + MarkdownMoveFile(this.path, this.load, List edits) + : edits = List.unmodifiable(edits); + final String path; + final WorkspaceFileLoad load; + final List edits; + String get result { + var text = load.text; + for (final edit in edits.reversed) { + text = text.replaceRange(edit.start, edit.end, edit.after); + } + return text; + } +} + +class MarkdownMovePlan { + MarkdownMovePlan({ + required this.workspaceId, + required this.root, + required this.source, + required this.destination, + required this.anchor, + required Map mapping, + required Map snapshots, + required Map identities, + required List inventory, + required List files, + }) : mapping = Map.unmodifiable(mapping), + snapshots = Map.unmodifiable(snapshots), + identities = Map.unmodifiable(identities), + inventory = List.unmodifiable(inventory), + files = List.unmodifiable(files); + final String workspaceId, root, source, destination; + final CanonicalPathAnchor anchor; + final Map mapping; + final Map snapshots; + final Map identities; + final List inventory; + final List files; +} + +class MarkdownRecoveryFile { + const MarkdownRecoveryFile(this.path, this.load, this.before); + final String path; + final WorkspaceFileLoad load; + final List before; +} + +class MarkdownRecoveryPlan { + MarkdownRecoveryPlan({ + required this.root, + required this.journal, + required this.journalSnapshot, + required this.source, + required this.destination, + required this.moved, + required this.moveEvidence, + required List files, + }) : files = List.unmodifiable(files); + final String root, journal, source, destination; + final WorkspaceFileSnapshot journalSnapshot; + final bool moved; + final String moveEvidence; + final List files; +} + +/// Uses ordinary workspace publication and lossless destination edits. A durable +/// journal retains both versions if a process dies between file writes and move. +class MarkdownMoveService { + const MarkdownMoveService({this.workspaceService = const WorkspaceService()}); + final WorkspaceService workspaceService; + String remap(String path, String source, String destination) => + p.equals(path, source) + ? destination + : p.isWithin(source, path) + ? p.join(destination, p.relative(path, from: source)) + : path; + Future prepare( + Workspace workspace, + String source, + String destination, + ) async { + final anchor = await captureCanonicalDirectoryAnchor(workspace.rootPath); + if (!p.equals(anchor.rootPath, anchor.requestedRootPath)) { + throw StateError('Workspace path changed.'); + } + source = p.normalize(p.absolute(source)); + destination = p.normalize(p.absolute(destination)); + final from = await resolveAnchoredPath(anchor, source, allowRoot: false); + final to = await resolveAnchoredPath(anchor, destination, allowRoot: false); + if (from.type == FileSystemEntityType.notFound || + to.type != FileSystemEntityType.notFound || + p.isWithin(source, destination)) { + throw StateError('The move destination is unavailable.'); + } + if (workspace.writersideProject != null && + (from.type == FileSystemEntityType.directory || + workspace.writersideProject!.modules.any( + (m) => m.topics.any((t) => p.equals(t.filePath, source)), + ))) { + throw StateError( + 'Use the established Writerside reviewed refactoring for owned topics.', + ); + } + final mapping = {source: destination}; + if (from.type == FileSystemEntityType.directory) { + await for (final entity in Directory( + source, + ).list(recursive: true, followLinks: false)) { + await resolveAnchoredPath(anchor, entity.path, allowRoot: false); + mapping[entity.path] = remap(entity.path, source, destination); + } + } + final inventory = await _inventory(workspace.rootPath); + final snapshots = {}; + final identities = {}; + for (final path in mapping.keys) { + if (await FileSystemEntity.type(path, followLinks: false) == + FileSystemEntityType.file) { + snapshots[path] = await workspaceService.fileSnapshot(path); + } + } + final files = []; + for (final path in inventory) { + await resolveAnchoredPath(anchor, path, allowRoot: false); + final load = await workspaceService.loadTextWithSnapshot(path); + if (load.format.hasMixedLineEndings) { + throw StateError('Review requires consistent line endings: $path'); + } + final text = load.text; + final references = await _scanMoveReferences(path, text); + final edits = {}; + final movedSource = remap(path, source, destination); + for (final ref in references) { + final uri = Uri.tryParse(ref.destination); + final span = ref.destinationSpan; + if (uri == null || + uri.hasScheme || + uri.hasAuthority || + uri.path.isEmpty || + span == null) { + continue; + } + final target = p.normalize( + p.join(p.dirname(path), Uri.decodeComponent(uri.path)), + ); + if (!p.isWithin(workspace.rootPath, target)) continue; + final movedTarget = remap(target, source, destination); + if (p.equals(path, movedSource) && p.equals(target, movedTarget)) { + continue; + } + await resolveAnchoredPath( + anchor, + target, + allowRoot: false, + allowMissingAncestors: true, + ); + var replacement = p + .relative(movedTarget, from: p.dirname(movedSource)) + .split('/') + .map( + (part) => Uri.encodeComponent( + part, + ).replaceAll('(', '%28').replaceAll(')', '%29'), + ) + .join('/'); + if (uri.hasQuery) replacement += '?${uri.query}'; + if (uri.hasFragment) replacement += '#${uri.fragment}'; + if (ref.kind == AuthoredReferenceKind.htmlLink || + ref.kind == AuthoredReferenceKind.htmlMedia) { + replacement = const HtmlEscape( + HtmlEscapeMode.attribute, + ).convert(replacement); + } + final before = text.substring(span.startOffset, span.endOffset); + if (before != replacement) { + edits[span.startOffset] = MarkdownDestinationEdit( + span.startOffset, + span.endOffset, + before, + replacement, + ); + } + } + snapshots[path] = load.snapshot; + if (edits.isNotEmpty) { + files.add( + MarkdownMoveFile( + path, + load, + edits.values.toList()..sort((a, b) => a.start.compareTo(b.start)), + ), + ); + } + } + for (final path in {...mapping.keys, ...snapshots.keys}) { + identities[path] = await FileStat.stat(path); + } + return MarkdownMovePlan( + workspaceId: workspace.id, + root: workspace.rootPath, + source: source, + destination: destination, + anchor: anchor, + mapping: mapping, + snapshots: snapshots, + identities: identities, + inventory: inventory, + files: files, + ); + } + + Future> _inventory(String root) async { + final paths = []; + final pending = [Directory(root)]; + while (pending.isNotEmpty) { + await for (final entity in pending.removeLast().list( + followLinks: false, + )) { + if (const {'.git', '.hg', '.svn'}.contains(p.basename(entity.path))) { + continue; + } + if (entity is Link) { + final type = await FileSystemEntity.type(entity.path); + if (type == FileSystemEntityType.directory || + { + '.md', + '.markdown', + }.contains(p.extension(entity.path).toLowerCase())) { + throw StateError( + 'Cannot inspect linked source safely: ${entity.path}', + ); + } + } else if (entity is Directory) { + pending.add(entity); + } else if (entity is File && + { + '.md', + '.markdown', + }.contains(p.extension(entity.path).toLowerCase())) { + paths.add(entity.path); + } + } + } + paths.sort(); + return paths; + } + + Future validate(MarkdownMovePlan plan) async { + final inventory = await _inventory(plan.root); + if (inventory.join('\n') != plan.inventory.join('\n')) { + throw StateError('Workspace files changed; review again.'); + } + final source = await resolveAnchoredPath( + plan.anchor, + plan.source, + allowRoot: false, + ); + final destination = await resolveAnchoredPath( + plan.anchor, + plan.destination, + allowRoot: false, + ); + if (source.type == FileSystemEntityType.notFound || + destination.type != FileSystemEntityType.notFound) { + throw StateError('Move paths changed; review again.'); + } + for (final entry in plan.snapshots.entries) { + await resolveAnchoredPath(plan.anchor, entry.key, allowRoot: false); + if ((await workspaceService.fileSnapshot( + entry.key, + )).differsFrom(entry.value)) { + throw StateError('Reviewed file changed: ${entry.key}'); + } + } + for (final entry in plan.identities.entries) { + final current = await FileStat.stat(entry.key); + final before = entry.value; + if (current.type != before.type || + current.changed != before.changed || + current.modified != before.modified || + current.mode != before.mode || + current.size != before.size) { + throw StateError('Reviewed filesystem identity changed: ${entry.key}'); + } + } + if (source.type == FileSystemEntityType.directory) { + final paths = {plan.source}; + await for (final entity in Directory( + plan.source, + ).list(recursive: true, followLinks: false)) { + await resolveAnchoredPath(plan.anchor, entity.path, allowRoot: false); + paths.add(entity.path); + } + if (paths.length != plan.mapping.length || + !paths.containsAll(plan.mapping.keys)) { + throw StateError('Moved folder membership changed; review again.'); + } + } + } + + Future apply( + MarkdownMovePlan plan, { + required Future Function() move, + required void Function() validateBuffers, + }) async { + validateBuffers(); + await validate(plan); + validateBuffers(); + final folder = p.join(plan.root, '.busymark-refactoring'); + await resolveAnchoredPath(plan.anchor, folder, allowRoot: false); + await Directory(folder).create(); + final journal = File(p.join(folder, '${const Uuid().v4()}.json')); + final data = { + 'version': 2, + 'phase': 'prepared', + 'source': p.relative(plan.source, from: plan.root), + 'destination': p.relative(plan.destination, from: plan.root), + 'moveEntries': await _moveEvidence(plan, rewritten: false), + 'files': [ + for (final file in plan.files) + { + 'path': p.relative(file.path, from: plan.root), + 'before': base64Encode(file.load.format.encode(file.load.text)), + 'after': base64Encode(file.load.format.encode(file.result)), + }, + ], + }; + await const AtomicFileWriter().writeBytes( + journal.path, + utf8.encode(jsonEncode(data)), + overwrite: false, + ); + try { + await validate(plan); + validateBuffers(); + final writtenIdentities = {}; + await workspaceService.saveFormattedTextBatch( + [ + for (final file in plan.files) + WorkspaceBatchTextWrite( + path: file.path, + text: file.result, + expectedSnapshot: file.load.snapshot, + format: file.load.format, + ), + ], + validateBeforeCommit: validateBuffers, + onCommitted: (_) { + for (final file in plan.files) { + writtenIdentities[file.path] = LinuxFileIdentity.read(file.path); + } + }, + ); + validateBuffers(); + // Atomic rewrites can replace the moved file's inode. Persist the actual + // complete post-write identities BEFORE the rename, including files with + // no reference edits. A crash cannot leave an unrecorded move eligible + // for pathname-only inference. + data['moveEntries'] = await _moveEvidence( + plan, + rewritten: true, + expectedIdentities: { + for (final entry in data['moveEntries'] as List>) + p.join(plan.root, entry['path'] as String): + entry['identity'] as String?, + ...writtenIdentities, + }, + ); + data['phase'] = 'moving'; + await const AtomicFileWriter().writeBytes( + journal.path, + utf8.encode(jsonEncode(data)), + overwrite: true, + ); + await prepareRecovery(plan.root, journal.path); + // Move revalidates canonical paths and collisions through WorkspaceService. + final target = await move(); + if (!p.equals(target, plan.destination)) { + throw StateError('Unexpected move result.'); + } + final completed = await prepareRecovery(plan.root, journal.path); + if (!completed.moved) throw StateError('Moved identity changed.'); + await journal.delete(); + return target; + } on Object { + // Rollback compares exact post-operation bytes. Newer external content is + // never replaced, and the journal survives any incomplete restoration. + await recover(plan.root, journal.path); + rethrow; + } + } + + Future>> _moveEvidence( + MarkdownMovePlan plan, { + required bool rewritten, + Map? expectedIdentities, + }) async { + final result = >[]; + for (final path in plan.mapping.keys) { + await resolveAnchoredPath(plan.anchor, path, allowRoot: false); + final identity = LinuxFileIdentity.read(path); + if (identity == null) { + throw UnsupportedError('Durable move identity is unavailable: $path'); + } + if (expectedIdentities != null && expectedIdentities[path] != identity) { + throw StateError('Moved filesystem identity changed: $path'); + } + final type = await FileSystemEntity.type(path, followLinks: false); + final changed = plan.files + .where((f) => p.equals(f.path, path)) + .firstOrNull; + final before = plan.snapshots[path]?.contentHash; + final after = changed == null + ? before + : sha256 + .convert(changed.load.format.encode(changed.result)) + .toString(); + if (type == FileSystemEntityType.file) { + final snapshot = await workspaceService.fileSnapshot(path); + if (snapshot.contentHash != (rewritten ? after : before) || + LinuxFileIdentity.read(path) != identity) { + throw StateError('Moved source changed: $path'); + } + } else if (type != FileSystemEntityType.directory) { + throw StateError('Unsupported moved entry: $path'); + } + result.add({ + 'path': p.relative(path, from: plan.root), + 'type': type == FileSystemEntityType.directory ? 'directory' : 'file', + 'identity': identity, + if (before != null) 'hashes': [before, after], + }); + } + return result; + } + + Future recover( + String root, + String journalPath, { + bool undoMove = true, + }) async { + final plan = await prepareRecovery(root, journalPath); + await recoverReviewed(plan, undoMove: undoMove); + } + + Future prepareRecovery( + String root, + String journalPath, + ) async { + final anchor = await captureCanonicalDirectoryAnchor(root); + if (!p.equals( + p.dirname(journalPath), + p.join(root, '.busymark-refactoring'), + ) || + p.extension(journalPath) != '.json') { + throw StateError('Invalid refactoring journal location.'); + } + await resolveAnchoredPath(anchor, journalPath, allowRoot: false); + final journalSnapshot = await workspaceService.fileSnapshot(journalPath); + final data = + jsonDecode(await File(journalPath).readAsString()) + as Map; + if (data['version'] != 2 || + !{'prepared', 'moving'}.contains(data['phase'])) { + // Older journals lack moved-object identity. Keep that evidence for + // manual review rather than guess which existing file was moved. + throw StateError( + 'Refactoring journal lacks verified move identity; manual review is required.', + ); + } + String safe(Object? relative) => _safeJournalPath(root, relative); + + final source = safe(data['source']), + destination = safe(data['destination']); + await resolveAnchoredPath(anchor, source, allowRoot: false); + await resolveAnchoredPath(anchor, destination, allowRoot: false); + final sourceExists = + await FileSystemEntity.type(source, followLinks: false) != + FileSystemEntityType.notFound; + final destinationExists = + await FileSystemEntity.type(destination, followLinks: false) != + FileSystemEntityType.notFound; + if (sourceExists == destinationExists || + data['phase'] == 'prepared' && destinationExists) { + throw StateError('Move paths changed; recovery evidence is retained.'); + } + final moved = destinationExists; + final entries = data['moveEntries'] as List; + await _validateMoveEntries( + anchor, + source, + destination, + entries, + moved: moved, + ); + final files = []; + for (final raw in data['files'] as List) { + final original = safe(raw['path']); + final path = moved ? remap(original, source, destination) : original; + await resolveAnchoredPath(anchor, path, allowRoot: false); + final current = await File(path).readAsBytes(); + final before = base64Decode(raw['before'] as String), + after = base64Decode(raw['after'] as String); + if (base64Encode(current) == base64Encode(before)) continue; + if (base64Encode(current) != base64Encode(after)) { + throw StateError('Recovery needs review of newer content: $path'); + } + final load = await workspaceService.loadTextWithSnapshot(path); + if (base64Encode(load.format.encode(load.text)) != base64Encode(after)) { + throw StateError('Recovery source changed: $path'); + } + files.add(MarkdownRecoveryFile(path, load, List.unmodifiable(before))); + } + if ((await workspaceService.fileSnapshot( + journalPath, + )).differsFrom(journalSnapshot)) { + throw StateError('Recovery evidence changed.'); + } + return MarkdownRecoveryPlan( + root: root, + journal: journalPath, + journalSnapshot: journalSnapshot, + source: source, + destination: destination, + moved: moved, + moveEvidence: jsonEncode(entries), + files: files, + ); + } + + String _safeJournalPath(String root, Object? relative) { + if (relative is! String || + p.isAbsolute(relative) || + p.split(relative).contains('..')) { + throw StateError('Unsafe refactoring journal.'); + } + return p.join(root, relative); + } + + Future _validateMoveEntries( + CanonicalPathAnchor anchor, + String source, + String destination, + List entries, { + required bool moved, + Map writtenIdentities = const {}, + }) async { + String safe(Object? relative) => + _safeJournalPath(anchor.rootPath, relative); + if (entries.isEmpty) throw StateError('Missing move identity evidence.'); + final paths = {}; + for (final raw in entries) { + final original = safe(raw['path']); + if (!(p.equals(original, source) || p.isWithin(source, original))) { + throw StateError('Invalid moved entry.'); + } + final path = moved ? remap(original, source, destination) : original; + if (!paths.add(path)) throw StateError('Duplicate moved entry.'); + await resolveAnchoredPath(anchor, path, allowRoot: false); + final identity = LinuxFileIdentity.read(path); + final type = await FileSystemEntity.type(path, followLinks: false); + if (identity == null || + identity != + (writtenIdentities.containsKey(path) + ? writtenIdentities[path] + : raw['identity']) || + (type == FileSystemEntityType.directory + ? 'directory' + : type == FileSystemEntityType.file + ? 'file' + : 'unsupported') != + raw['type']) { + throw StateError('Moved filesystem identity changed: $path'); + } + if (type == FileSystemEntityType.file && + !(raw['hashes'] as List).contains( + (await workspaceService.fileSnapshot(path)).contentHash, + )) { + throw StateError('Moved content changed: $path'); + } + if (LinuxFileIdentity.read(path) != identity) { + throw StateError('Moved filesystem identity changed: $path'); + } + } + final active = moved ? destination : source; + final membership = {active}; + if (await FileSystemEntity.type(active, followLinks: false) == + FileSystemEntityType.directory) { + await for (final entry in Directory( + active, + ).list(recursive: true, followLinks: false)) { + await resolveAnchoredPath(anchor, entry.path, allowRoot: false); + membership.add(entry.path); + } + } + if (paths.length != membership.length || !paths.containsAll(membership)) { + throw StateError('Moved folder membership changed.'); + } + } + + Future recoverReviewed( + MarkdownRecoveryPlan plan, { + bool undoMove = true, + void Function()? validateBuffers, + }) async { + validateBuffers?.call(); + if (plan.moved && !undoMove) { + throw StateError( + 'Restoring references requires restoring the moved identity.', + ); + } + final anchor = await captureCanonicalDirectoryAnchor(plan.root); + await resolveAnchoredPath(anchor, plan.journal, allowRoot: false); + final currentPlan = await prepareRecovery(plan.root, plan.journal); + if (currentPlan.moved != plan.moved || + currentPlan.source != plan.source || + currentPlan.destination != plan.destination || + currentPlan.journalSnapshot.differsFrom(plan.journalSnapshot) || + currentPlan.files.length != plan.files.length) { + throw StateError('Recovery changed; review again.'); + } + for (final file in plan.files) { + await resolveAnchoredPath(anchor, file.path, allowRoot: false); + if ((await workspaceService.fileSnapshot( + file.path, + )).differsFrom(file.load.snapshot)) { + throw StateError('Recovery source changed: ${file.path}'); + } + } + validateBuffers?.call(); + if (plan.moved && undoMove) { + await resolveAnchoredPath(anchor, plan.source, allowRoot: false); + await resolveAnchoredPath(anchor, plan.destination, allowRoot: false); + if (await FileSystemEntity.type(plan.source, followLinks: false) != + FileSystemEntityType.notFound) { + throw StateError('Recovery destination is occupied.'); + } + // Check identities immediately before a no-replace rename. Restore the + // moved object first so a recreated original path can never receive + // restored incoming references. + await prepareRecovery(plan.root, plan.journal); + final error = LinuxAtomicFileApi.instance.publishNoReplace( + plan.destination, + plan.source, + directory: true, + ); + if (error != null) { + throw FileSystemException( + 'Cannot restore moved identity (errno $error)', + plan.source, + ); + } + } + final restoredIdentities = {}; + final restoredPaths = { + for (final file in plan.files) + plan.moved + ? remap(file.path, plan.destination, plan.source) + : file.path, + }; + final unwrittenIdentities = { + for (final entry in jsonDecode(plan.moveEvidence) as List) + if (!restoredPaths.contains(_safeJournalPath(plan.root, entry['path']))) + _safeJournalPath(plan.root, entry['path']): + entry['identity'] as String, + }; + void validateRecoveryPublication() { + validateBuffers?.call(); + if (FileSystemEntity.typeSync(plan.destination, followLinks: false) != + FileSystemEntityType.notFound || + unwrittenIdentities.entries.any( + (entry) => LinuxFileIdentity.read(entry.key) != entry.value, + )) { + throw StateError('Moved identity changed during recovery publication.'); + } + } + + await workspaceService.saveFormattedTextBatch( + [ + for (final file in plan.files) + WorkspaceBatchTextWrite( + path: plan.moved && undoMove + ? remap(file.path, plan.destination, plan.source) + : file.path, + text: decodeUtf8Document(file.before).text, + format: decodeUtf8Document(file.before).format, + expectedSnapshot: file.load.snapshot, + ), + ], + validateBeforeCommit: validateRecoveryPublication, + onCommitted: (snapshots) { + for (final path in snapshots.keys) { + restoredIdentities[path] = LinuxFileIdentity.read(path); + } + }, + ); + // Revalidate even an unedited moved document after restoring references; + // a concurrent replacement must retain the recovery evidence. + if (await FileSystemEntity.type(plan.source, followLinks: false) == + FileSystemEntityType.notFound || + await FileSystemEntity.type(plan.destination, followLinks: false) != + FileSystemEntityType.notFound || + (await workspaceService.fileSnapshot( + plan.journal, + )).differsFrom(plan.journalSnapshot)) { + throw StateError('Recovery paths or evidence changed.'); + } + await _validateMoveEntries( + anchor, + plan.source, + plan.destination, + jsonDecode(plan.moveEvidence) as List, + moved: false, + writtenIdentities: restoredIdentities, + ); + await File(plan.journal).delete(); + } +} + +Future> _scanMoveReferences(String path, String text) => + Isolate.run(() => markdownReferences(identity: path, source: text)); diff --git a/lib/src/references/markdown_move_ui.dart b/lib/src/references/markdown_move_ui.dart new file mode 100644 index 00000000..3048ef00 --- /dev/null +++ b/lib/src/references/markdown_move_ui.dart @@ -0,0 +1,160 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../app/busymark_dialogs.dart'; +import '../app/busymark_design.dart'; +import '../app/localization.dart'; +import '../workspace/workspace_controller.dart'; +import '../workspace/workspace_model.dart'; +import '../workspace/workspace_safety.dart'; +import 'markdown_move_service.dart'; + +Future reviewMarkdownRecovery( + BuildContext context, + WidgetRef ref, + String journal, +) async { + final workspace = ref.read(workspaceControllerProvider).workspace; + if (workspace == null || + !await confirmSafeToChangeWorkspaceFiles( + context, + ref, + workspace.files.map((f) => f.absolutePath), + ) || + !context.mounted) { + return; + } + try { + final plan = await const MarkdownMoveService().prepareRecovery( + workspace.rootPath, + journal, + ); + if (!context.mounted || + ref.read(workspaceControllerProvider).workspace?.id != workspace.id) { + return; + } + final accepted = await showBusyMarkModalDialog( + context, + builder: (context) => BusyMarkDialogShell( + title: context.l10n.notesRecovery, + actions: [ + BusyMarkDialogButton( + label: context.l10n.cancel, + onPressed: () => Navigator.pop(context, false), + ), + BusyMarkDialogButton( + label: context.l10n.apply, + onPressed: () => Navigator.pop(context, true), + ), + ], + children: [ + Text('${plan.destination} → ${plan.source}'), + SizedBox( + height: 360, + child: ListView( + children: [for (final file in plan.files) Text(file.path)], + ), + ), + ], + ), + ); + if (accepted == true) { + await ref + .read(workspaceControllerProvider.notifier) + .recoverMarkdownMove(plan); + } + } on Object catch (error) { + if (context.mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(error.toString()))); + } + } +} + +Future<({bool proceed, MarkdownMovePlan? plan})> reviewMarkdownMove( + BuildContext context, + WidgetRef ref, + String source, + String destination, +) async { + final workspace = ref.read(workspaceControllerProvider).workspace; + if (workspace?.kind != WorkspaceKind.markdownFolder) { + return (proceed: true, plan: null); + } + if (!await confirmSafeToChangeWorkspaceFiles( + context, + ref, + workspace!.files.map((f) => f.absolutePath), + ) || + !context.mounted) { + return (proceed: false, plan: null); + } + try { + final plan = await ref + .read(workspaceControllerProvider.notifier) + .prepareMarkdownMove(source, destination); + if (plan == null || + !context.mounted || + ref.read(workspaceControllerProvider).workspace?.id != workspace.id) { + return (proceed: false, plan: null); + } + final focus = FocusManager.instance.primaryFocus; + final accepted = await showBusyMarkModalDialog( + context, + builder: (context) => BusyMarkDialogShell( + title: context.l10n.rename, + maxWidth: 720, + actions: [ + BusyMarkDialogButton( + onPressed: () => Navigator.pop(context, false), + label: context.l10n.cancel, + ), + BusyMarkDialogButton( + onPressed: () => Navigator.pop(context, true), + label: context.l10n.apply, + ), + ], + children: [ + Text('$source → $destination'), + SizedBox( + height: 360, + child: ListView( + children: [ + for (final file in plan.files) ...[ + Text( + file.path, + style: Theme.of(context).textTheme.titleSmall, + ), + for (final edit in file.edits) + SelectableText( + '${edit.start}: ${edit.before} → ${edit.after}', + ), + ], + ], + ), + ), + ], + ), + ); + if (focus?.context?.mounted == true) focus!.requestFocus(); + return (proceed: accepted == true, plan: plan); + } on Object catch (error) { + if (context.mounted) { + await showBusyMarkModalDialog( + context, + builder: (context) => BusyMarkDialogShell( + title: context.l10n.rename, + actions: [ + BusyMarkDialogButton( + onPressed: () => Navigator.pop(context), + label: context.l10n.close, + ), + ], + children: [Text(error.toString())], + ), + ); + } + return (proceed: false, plan: null); + } +} diff --git a/lib/src/references/markdown_references.dart b/lib/src/references/markdown_references.dart new file mode 100644 index 00000000..a731950a --- /dev/null +++ b/lib/src/references/markdown_references.dart @@ -0,0 +1,252 @@ +// These are source adapters for BusyMark's supported grammar. Candidates are +// accepted by the existing parser, never by a link-looking regular expression. +// ignore: implementation_imports +import 'package:markdown/src/link_parser.dart' show LinkParser; +// ignore: implementation_imports +import 'package:markdown/src/util.dart' show normalizeLinkLabel; + +import '../core/source_span.dart'; +import '../markdown/busymark_document.dart'; +import '../markdown/markdown_model.dart'; +import '../markdown/markdown_parser.dart'; +import '../markdown/markdown_source_map.dart'; +import '../writerside/writerside_html_reference_scanner.dart'; + +enum AuthoredReferenceKind { + link, + referenceLink, + htmlLink, + media, + htmlMedia, + topic, + include, +} + +class AuthoredReference { + const AuthoredReference({ + required this.destination, + required this.kind, + required this.occurrence, + required this.destinationSpan, + this.label = '', + this.origin, + this.moduleId, + }); + final String destination, label; + final AuthoredReferenceKind kind; + final SourceSpan occurrence; + final SourceSpan? destinationSpan; + final String? origin, moduleId; +} + +({int start, int end})? markdownDestinationRange( + String source, + int start, + int limit, +) { + while (start < limit && RegExp(r'\s').hasMatch(source[start])) { + start++; + } + if (start >= limit) return null; + var end = start; + if (source[start] == '<') { + start++; + end = start; + while (end < limit) { + if (source[end] == '\\') { + end += 2; + continue; + } + if (source[end] == '>') return (start: start, end: end); + end++; + } + return null; + } + var depth = 0; + while (end < limit) { + final char = source[end]; + if (char == '\\') { + end += 2; + continue; + } + if (char == '(') depth++; + if (char == ')') { + if (depth == 0) break; + depth--; + } + if (RegExp(r'\s').hasMatch(char)) break; + end++; + } + return end <= limit ? (start: start, end: end) : null; +} + +List markdownReferences({ + required String identity, + required String source, + MarkdownMode mode = MarkdownMode.commonMark, +}) { + final parsed = const MarkdownParser().parse( + filePath: identity, + source: source, + mode: mode, + validateLocalReferences: false, + ); + final context = const MarkdownSourceMapper().createInlineParserContext( + documentSource: source, + mode: mode, + ); + final mask = writersideMarkdownLiteralMask( + source: source, + protectedRanges: parsed.codeBlocks.map((b) => b.span), + ); + SourceSpan span(int start, int end) => SourceSpan.fromOffsets( + filePath: identity, + source: source, + startOffset: start, + endOffset: end, + ); + final definitions = {}; + // LinkParser validates balanced/escaped destinations, multiline definitions + // and titles. A parser probe proves this is the winning declaration, including + // container/prose/code context and CommonMark's first-definition rule. + for (final match in RegExp( + r'\[(?:\\.|[^\]\\]){1,999}\]:', + ).allMatches(source)) { + if (mask[match.start]) continue; + final blank = source.indexOf(RegExp(r'\n[ \t]*\n'), match.start); + final limit = blank < 0 ? source.length : blank; + final parser = LinkParser( + source + .substring(match.start, limit) + .replaceAll(RegExp(r'(?<=\n) {0,3}(?:> ?)+'), ''), + )..parseDefinition(); + if (!parser.valid || parser.label == null) continue; + final label = normalizeLinkLabel(parser.label!); + if (definitions.containsKey(label) || + !context.referenceDefinitions.containsKey(label)) { + continue; + } + var destinationStart = match.end; + while (destinationStart < limit && + RegExp(r'[\s>]').hasMatch(source[destinationStart])) { + destinationStart++; + } + final range = markdownDestinationRange(source, destinationStart, limit); + if (range == null) continue; + var probe = 'https://busymark-reference.invalid/definition'; + while (source.contains(probe)) { + probe += 'x'; + } + final candidate = source.replaceRange(range.start, range.end, probe); + final verified = const MarkdownSourceMapper() + .createInlineParserContext(documentSource: candidate, mode: mode) + .referenceDefinitions[label]; + if (verified?.destination == probe) { + definitions[label] = span(range.start, range.end); + } + } + final result = []; + final seen = {}; + for (final mapping in context.parsePositionedBlocks( + source, + includeCodeBlocks: false, + )) { + for (final entry in mapping.ranges.entries) { + final inline = entry.key; + final range = entry.value; + if (inline.destination == null || + !{BusyInlineKind.link, BusyInlineKind.image}.contains(inline.kind) || + range.start < 0 || + range.end > source.length || + range.start == range.end || + // The parser owns label semantics. Code spans *inside* link text + // remain formatting; only a link starting in literal code/comment + // context is excluded. + mask[range.start]) { + continue; + } + if (!seen.add('${range.start}:${range.end}')) continue; + SourceSpan? destination; + if (range.isReference) { + final raw = source.substring(range.start, range.end); + final suffix = RegExp(r'\[([^\]]*)\]$').firstMatch(raw)?.group(1); + final label = suffix?.isNotEmpty == true + ? suffix! + : range.labelStart != null && range.labelEnd != null + ? source.substring(range.labelStart!, range.labelEnd!) + : ''; + destination = definitions[normalizeLinkLabel(label)]; + } else if (range.isAutolink) { + destination = source[range.start] == '<' + ? span(range.start + 1, range.end - 1) + : span(range.start, range.end); + } else if (range.labelEnd != null && + range.labelEnd! + 1 < range.end && + source[range.labelEnd! + 1] == '(') { + final found = markdownDestinationRange( + source, + range.labelEnd! + 2, + range.end, + ); + if (found != null) destination = span(found.start, found.end); + } + if (destination == null) { + continue; // Authored HTML is mapped by its tokenizer below. + } + result.add( + AuthoredReference( + destination: inline.destination!, + kind: inline.kind == BusyInlineKind.image + ? AuthoredReferenceKind.media + : range.isReference + ? AuthoredReferenceKind.referenceLink + : AuthoredReferenceKind.link, + occurrence: span(range.start, range.end), + destinationSpan: destination, + label: inline.text, + ), + ); + } + } + for (final html + in const WritersideAuthoredHtmlReferenceScanner().scanMarkdownAnchors( + filePath: identity, + source: source, + protectedSource: mask, + )) { + result.add( + AuthoredReference( + destination: html.href, + kind: AuthoredReferenceKind.htmlLink, + occurrence: html.anchorSpan ?? html.startTagSpan, + destinationSpan: html.hrefSpan, + label: html.innerContentSpan == null + ? '' + : source.substring( + html.innerContentSpan!.startOffset, + html.innerContentSpan!.endOffset, + ), + origin: html.origin, + ), + ); + } + for (final media + in const WritersideAuthoredHtmlReferenceScanner().scanMarkdownMedia( + filePath: identity, + source: source, + protectedSource: mask, + )) { + result.add( + AuthoredReference( + destination: media.destination, + kind: AuthoredReferenceKind.htmlMedia, + occurrence: media.span, + destinationSpan: media.span, + ), + ); + } + result.sort( + (a, b) => a.occurrence.startOffset.compareTo(b.occurrence.startOffset), + ); + return List.unmodifiable(result); +} diff --git a/lib/src/references/reference_controller.dart b/lib/src/references/reference_controller.dart new file mode 100644 index 00000000..3bf2c730 --- /dev/null +++ b/lib/src/references/reference_controller.dart @@ -0,0 +1,302 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:isolate'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:path/path.dart' as p; + +import '../nextcloud_notes/application/nextcloud_connection.dart'; +import '../nextcloud_notes/application/notes_navigation.dart'; +import '../nextcloud_notes/domain/notes_models.dart'; +import '../workspace/workspace_controller.dart'; +import '../workspace/workspace_model.dart'; +import '../markdown/markdown_model.dart'; +import 'reference_index.dart'; +import 'writerside_reference_adapter.dart'; + +final referenceIndexProvider = + NotifierProvider>( + ReferenceController.new, + ); + +typedef ReferenceBatchParser = + Future> Function( + List documents, + ); +final referenceBatchParserProvider = Provider( + (ref) => parseReferenceBatch, +); + +/// Coalesces edits and filesystem/repository notifications outside widget builds. +/// Content digests retain unchanged parsing; generations fence obsolete results. +class ReferenceController extends Notifier> { + Timer? _timer; + StreamSubscription? _notes; + int _generation = 0; + final _cache = {}; + @override + AsyncValue build() { + ref.listen(workspaceControllerProvider, (before, after) => refresh()); + ref.listen(nextcloudNotesRepositoryProvider, (before, after) { + _notes?.cancel(); + _notes = after.value?.changes.listen((_) => refresh()); + refresh(); + }, fireImmediately: true); + ref.onDispose(() { + _generation++; + _timer?.cancel(); + _notes?.cancel(); + }); + refresh(); + return const AsyncLoading(); + } + + void refresh() { + final generation = ++_generation; + _timer?.cancel(); + _timer = Timer( + const Duration(milliseconds: 200), + () => _rebuild(generation), + ); + } + + Future _rebuild(int generation) async { + try { + final snapshot = ref.read(workspaceControllerProvider); + final workspace = snapshot.workspace; + if (workspace == null) { + if (generation == _generation && ref.mounted) { + _cache.clear(); + state = AsyncData(ReferenceIndex(parsed: const [])); + } + return; + } + final documents = []; + Uri? server; + final missingNoteIds = {}; + String? root; + if (workspace.isRemote) { + final repository = await ref.read( + nextcloudNotesRepositoryProvider.future, + ); + server = repository.accountById(workspace.nextcloudAccountId!)?.server; + missingNoteIds.addAll( + repository.notes + .where( + (n) => + n.accountId == workspace.nextcloudAccountId && + n.syncState == NoteSyncState.deletedRemotely && + n.serverId != null, + ) + .map((n) => n.serverId!), + ); + for (final note in repository.notes.where( + (n) => + n.accountId == workspace.nextcloudAccountId && + !isNotesRecovery(n), + )) { + final buffer = snapshot.documentBuffers + .where((b) => b.remoteNote?.localId == note.localId) + .firstOrNull; + documents.add( + ReferenceDocument( + identity: note.localId, + title: note.title, + source: buffer?.text ?? note.content, + localId: note.localId, + serverId: note.serverId, + available: !note.error, + ), + ); + } + } else { + root = workspace.filesystemRootPath; + for (final file in workspace.files.where( + (f) => { + DocumentKind.markdown, + DocumentKind.writersideMarkdownTopic, + DocumentKind.writersideXmlTopic, + }.contains(f.kind), + )) { + if (generation != _generation || !ref.mounted) return; + final buffer = snapshot.documentBuffers + .where((b) => b.filePath == file.absolutePath) + .firstOrNull; + var source = buffer?.text; + var available = true; + if (source == null) { + try { + final disk = File(file.absolutePath); + final canonical = await disk.resolveSymbolicLinks(); + final canonicalRoot = await Directory( + root!, + ).resolveSymbolicLinks(); + if (!p.isWithin(canonicalRoot, canonical) || + !p.equals(canonical, file.absolutePath) || + (await disk.stat()).size > 2 * 1024 * 1024) { + throw const FileSystemException( + 'Unavailable reference source.', + ); + } + source = await disk.readAsString(); + } on Object { + available = false; + source = ''; + } + } + documents.add( + ReferenceDocument( + identity: file.absolutePath, + path: file.absolutePath, + title: p.basenameWithoutExtension(file.relativePath), + source: source, + available: available, + mode: file.kind == DocumentKind.writersideMarkdownTopic + ? MarkdownMode.writersideMarkdown + : MarkdownMode.commonMark, + ), + ); + } + } + final parsed = []; + final changed = []; + for (final document in documents) { + final cached = _cache[document.identity]; + if (cached?.document.revision == document.revision && + cached?.document.available == document.available && + cached?.document.title == document.title && + cached?.document.mode == document.mode && + cached?.document.serverId == document.serverId) { + parsed.add(cached!); + } else { + changed.add(document); + } + } + // A worker transfer is limited to 16 documents, never the entire corpus. + for (var offset = 0; offset < changed.length; offset += 16) { + final batch = changed.skip(offset).take(16).toList(); + final values = await ref.read(referenceBatchParserProvider)(batch); + if (generation != _generation || !ref.mounted) return; + parsed.addAll(values); + // Keep completed, current batches when a later edit supersedes the + // rebuild. Their revisions are checked before reuse on the next pass. + _cache.addEntries(values.map((d) => MapEntry(d.document.identity, d))); + } + final writerside = workspace.writersideProject == null + ? null + : await ref + .read(workspaceControllerProvider.notifier) + .writersideEditorIndex(); + if (generation != _generation || !ref.mounted) return; + _cache + ..clear() + ..addEntries(parsed.map((d) => MapEntry(d.document.identity, d))); + final index = ReferenceIndex( + parsed: parsed, + root: root, + server: server, + missingNoteIds: missingNoteIds, + previous: state.value, + ownedPaths: + writerside?.modulesById.values + .expand((m) => m.topics) + .map((t) => t.filePath) + .toSet() ?? + const {}, + ); + if (writerside != null) { + addWritersideReferences( + index, + writerside, + workspace.writersideProject?.activeInstanceId, + ); + } + state = AsyncData(index); + } on Object catch (error, stack) { + if (ref.mounted && generation == _generation) { + state = AsyncError(error, stack); + } + } + } +} + +final referencePanelRequestProvider = + NotifierProvider(ReferencePanelRequest.new); + +class ReferencePanelRequest extends Notifier { + @override + int build() => 0; + void request() => state++; +} + +Future> parseReferenceBatch( + List documents, +) => Isolate.run(() => documents.map(ParsedReferenceDocument.new).toList()); + +typedef ReferenceQuery = ({ + String identity, + bool incoming, + String? anchor, + int count, +}); + +class ReferencePanelResults { + const ReferencePanelResults(this.headings, this.references, this.total); + final List headings; + final List references; + final int total; +} + +// Cached queries keep corpus traversal out of widget builds. +final referencePanelResultsProvider = Provider.autoDispose + .family, ReferenceQuery>((ref, query) { + return ref.watch(referenceIndexProvider).whenData((index) { + final values = []; + var total = 0; + for (var offset = 0; offset < query.count; offset += 100) { + final page = index.query( + query.identity, + incoming: query.incoming, + anchor: query.anchor, + offset: offset, + ); + values.addAll(page.values); + total = page.total; + if (page.nextOffset == null) break; + } + final headings = { + for (final heading in index.headingsFor(query.identity)) + heading.anchor!: heading, + }.values.toList(); + return ReferencePanelResults( + headings, + List.unmodifiable(values), + total, + ); + }); + }); + +typedef ReferenceTargetQuery = ({String text, int count}); +final referenceTargetResultsProvider = Provider.autoDispose + .family< + AsyncValue<({List values, int total})>, + ReferenceTargetQuery + >((ref, query) { + return ref.watch(referenceIndexProvider).whenData((index) { + final values = []; + var total = 0; + for (final target in index.targets) { + if (!'${target.document.title} ${target.label ?? ''} ${target.document.path ?? ''} ${target.moduleId ?? ''}' + .toLowerCase() + .contains(query.text)) { + continue; + } + total++; + if (values.length < query.count) values.add(target); + } + return ( + values: List.unmodifiable(values), + total: total, + ); + }); + }); diff --git a/lib/src/references/reference_index.dart b/lib/src/references/reference_index.dart new file mode 100644 index 00000000..1a78f0ac --- /dev/null +++ b/lib/src/references/reference_index.dart @@ -0,0 +1,366 @@ +import 'dart:convert'; + +import 'package:crypto/crypto.dart'; +import 'package:path/path.dart' as p; + +import '../core/source_span.dart'; +import '../markdown/markdown_model.dart'; +import '../markdown/markdown_parser.dart'; +import '../nextcloud_notes/data/server_uri.dart'; +import 'markdown_references.dart'; + +enum ReferenceResolution { + resolved, + missing, + ambiguous, + unavailable, + unsupported, +} + +String referenceRevision(String source) => + sha256.convert(utf8.encode(source)).toString(); + +class ReferenceDocument { + const ReferenceDocument({ + required this.identity, + required this.title, + required this.source, + this.path, + this.localId, + this.serverId, + this.mode = MarkdownMode.commonMark, + this.available = true, + }); + final String identity, title, source; + final String? path, localId; + final int? serverId; + final MarkdownMode mode; + final bool available; + String get revision => referenceRevision(source); +} + +class ReferenceTarget { + const ReferenceTarget( + this.document, { + this.anchor, + this.label, + this.span, + this.moduleId, + this.scopeSpan, + }); + final ReferenceDocument document; + final String? anchor, label, moduleId; + final SourceSpan? span; + final SourceSpan? scopeSpan; + String get key => '${document.identity}#${anchor ?? ''}'; +} + +class IndexedReference { + const IndexedReference({ + required this.source, + required this.authored, + required this.resolution, + this.target, + }); + final ReferenceDocument source; + final AuthoredReference authored; + final ReferenceResolution resolution; + final ReferenceTarget? target; + String get revision => source.revision; +} + +class ReferencePage { + const ReferencePage(this.values, this.total, this.nextOffset); + final List values; + final int total; + final int? nextOffset; +} + +class ParsedReferenceDocument { + ParsedReferenceDocument(this.document) + : headings = document.available + ? const MarkdownParser() + .parse( + filePath: document.identity, + source: document.source, + mode: document.mode, + validateLocalReferences: false, + ) + .headings + : const [], + authored = document.available + ? markdownReferences( + identity: document.identity, + source: document.source, + mode: document.mode, + ) + : const []; + final ReferenceDocument document; + final List headings; + final List authored; +} + +/// Entirely derived; no writes to notes, source, history or synchronization. +class ReferenceIndex { + ReferenceIndex({ + required List parsed, + this.root, + this.server, + Set missingNoteIds = const {}, + Set ownedPaths = const {}, + ReferenceIndex? previous, + }) : missingNoteIds = Set.unmodifiable(missingNoteIds) { + parsedDocuments = List.unmodifiable(parsed); + documents = { + for (final value in parsed) value.document.identity: value.document, + }; + final paths = { + for (final value in parsed) + if (value.document.path != null) + p.normalize(value.document.path!): value, + }; + final ids = { + for (final value in parsed) + if (value.document.serverId != null) value.document.serverId!: value, + }; + targets = [ + for (final value in parsed) ...[ + ReferenceTarget(value.document), + for (final heading + in ownedPaths.contains(value.document.path) + ? [] + : value.headings) + ReferenceTarget( + value.document, + anchor: heading.id, + label: heading.text, + span: heading.span, + ), + ], + ]; + references = []; + final reusable = + previous != null && + previous.root == root && + previous.server == server && + previous.missingNoteIds.length == missingNoteIds.length && + previous.missingNoteIds.every(missingNoteIds.contains) && + previous.documents.length == documents.length && + documents.entries.every((entry) { + final old = previous.documents[entry.key]; + return old != null && + old.path == entry.value.path && + old.serverId == entry.value.serverId; + }); + final owned = ownedPaths; + final allTargetsUnchanged = + reusable && + documents.entries.every( + (entry) => identical(previous.documents[entry.key], entry.value), + ); + for (final value in parsed) { + if (owned.contains(value.document.path)) continue; + if (reusable && + identical( + previous.documents[value.document.identity], + value.document, + )) { + final cached = + previous._outgoing[value.document.identity] ?? + const []; + if (cached.every( + (r) => + (r.target == null && + (allTargetsUnchanged || + r.resolution == ReferenceResolution.unsupported)) || + (r.target != null && + identical( + documents[r.target!.document.identity], + r.target!.document, + )), + )) { + references.addAll(cached); + continue; + } + } + for (final authored in value.authored) { + var resolution = ReferenceResolution.unsupported; + ReferenceTarget? target; + final uri = Uri.tryParse(authored.destination); + ParsedReferenceDocument? destination; + if (server != null) { + final noteId = nextcloudNoteId(server!, authored.destination); + if (noteId != null) { + if (missingNoteIds.contains(noteId)) { + resolution = ReferenceResolution.missing; + } else { + destination = ids[noteId]; + resolution = ReferenceResolution.unavailable; + } + } else if (authored.destination.startsWith('#')) { + destination = value; + } + } else if (uri != null && + !uri.hasScheme && + !uri.hasAuthority && + root != null) { + try { + final path = uri.path.isEmpty + ? value.document.path + : p.normalize( + p.join( + p.dirname(value.document.path!), + Uri.decodeComponent(uri.path), + ), + ); + if (path != null && + (p.equals(path, root!) || p.isWithin(root!, path))) { + destination = paths[path]; + resolution = + destination == null && + !const { + '.md', + '.markdown', + }.contains(p.extension(path).toLowerCase()) + ? ReferenceResolution.unsupported + : ReferenceResolution.missing; + } + } on FormatException { + /* malformed destinations remain unsupported */ + } + } + if (destination != null) { + resolution = destination.document.available + ? ReferenceResolution.resolved + : ReferenceResolution.unavailable; + String? anchor; + try { + // URI fragments retain percent encoding. Semantic IDs are decoded + // once, while authored.destination remains unchanged for edits. + anchor = uri == null ? null : Uri.decodeComponent(uri.fragment); + } on FormatException { + resolution = ReferenceResolution.unsupported; + } + SourceSpan? span; + if (anchor != null && + anchor.isNotEmpty && + destination.document.available) { + final matches = destination.headings + .where((h) => h.id == anchor) + .toList(); + resolution = matches.isEmpty + ? ReferenceResolution.missing + : matches.length > 1 + ? ReferenceResolution.ambiguous + : ReferenceResolution.resolved; + span = matches.singleOrNull?.span; + } + target = ReferenceTarget( + destination.document, + anchor: anchor?.isEmpty == true ? null : anchor, + span: span, + ); + } + references.add( + IndexedReference( + source: value.document, + authored: authored, + resolution: resolution, + target: target, + ), + ); + } + } + rebuildLookups(); + } + final String? root; + final Uri? server; + final Set missingNoteIds; + late final Map documents; + late final List parsedDocuments; + late final List targets; + late final List references; + final _incoming = >{}; + final _outgoing = >{}; + final _headings = >{}; + + /// Called by the format adapter before publishing a completed index. + void rebuildLookups() { + _incoming.clear(); + _outgoing.clear(); + _headings.clear(); + for (final target in targets.where((t) => t.anchor != null)) { + _headings.putIfAbsent(target.document.identity, () => []).add(target); + } + for (final headings in _headings.values) { + headings.sort( + (a, b) => + (a.span?.startOffset ?? 0).compareTo(b.span?.startOffset ?? 0), + ); + } + for (final reference in references) { + _outgoing.putIfAbsent(reference.source.identity, () => []).add(reference); + final identity = reference.target?.document.identity; + if (identity != null) { + _incoming.putIfAbsent(identity, () => []).add(reference); + } + } + } + + List headingsFor(String identity) => + List.unmodifiable(_headings[identity] ?? const []); + + ReferencePage query( + String identity, { + bool incoming = false, + String? anchor, + int offset = 0, + int limit = 100, + }) { + if (offset < 0 || limit < 1 || limit > 100) { + throw ArgumentError('Invalid reference page.'); + } + final candidates = + (incoming ? _incoming : _outgoing)[identity] ?? + const []; + final values = anchor == null + ? candidates + : candidates + .where( + (r) => incoming + ? r.target?.document.identity == identity && + r.target?.anchor == anchor + : r.source.identity == identity && + _insideHeading(r, anchor), + ) + .toList(); + return ReferencePage( + List.unmodifiable(values.skip(offset).take(limit)), + values.length, + offset + limit < values.length ? offset + limit : null, + ); + } + + bool _insideHeading(IndexedReference reference, String anchor) { + final headings = + (_headings[reference.source.identity] ?? const []) + .where((t) => t.span != null) + .toList(); + final selected = headings.where((h) => h.anchor == anchor).singleOrNull; + if (selected == null) return false; + final start = selected.span!.startOffset; + if (selected.scopeSpan case final scope?) { + return reference.authored.occurrence.startOffset >= scope.startOffset && + reference.authored.occurrence.startOffset < scope.endOffset; + } + final end = + headings + .where((h) => h.span!.startOffset > start) + .firstOrNull + ?.span + ?.startOffset ?? + reference.source.source.length; + return reference.authored.occurrence.startOffset >= start && + reference.authored.occurrence.startOffset < end; + } +} diff --git a/lib/src/references/reference_ui.dart b/lib/src/references/reference_ui.dart new file mode 100644 index 00000000..0d18d98c --- /dev/null +++ b/lib/src/references/reference_ui.dart @@ -0,0 +1,479 @@ +import '../editor/link_destination.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:path/path.dart' as p; + +import '../app/busymark_design.dart'; +import '../app/busymark_dialogs.dart'; +import '../app/busymark_glyphs.dart'; +import '../app/localization.dart'; +import '../app/app_settings.dart'; +import '../nextcloud_notes/data/server_uri.dart'; +import '../nextcloud_notes/application/nextcloud_connection.dart'; +import '../nextcloud_notes/presentation/notes_workspace_ui.dart'; +import '../workspace/workspace_controller.dart'; +import 'reference_controller.dart'; +import 'reference_index.dart'; +import 'markdown_move_ui.dart'; +import 'dart:io'; +import 'dart:async'; + +Future showReferenceLinkChooser( + BuildContext context, { + String initialDestination = '', +}) async { + final focus = FocusManager.instance.primaryFocus; + final value = await showBusyMarkModalDialog( + context, + builder: (_) => + ReferenceLinkChooser(initialDestination: initialDestination), + ); + if (focus?.context?.mounted == true) focus!.requestFocus(); + return value; +} + +class ReferenceLinkChooser extends ConsumerStatefulWidget { + const ReferenceLinkChooser({super.key, this.initialDestination = ''}); + final String initialDestination; + @override + ConsumerState createState() => + _ReferenceLinkChooserState(); +} + +class _ReferenceLinkChooserState extends ConsumerState { + late final _url = TextEditingController(text: widget.initialDestination); + String _query = ''; + int _limit = 100; + late final String? _workspaceId; + late final String? _bufferId; + bool? _prettyUrls; + String? _routeFailure; + @override + void initState() { + super.initState(); + final state = ref.read(workspaceControllerProvider); + _workspaceId = state.workspace?.id; + _bufferId = state.activeBufferId; + if (state.workspace?.nextcloudAccountId case final id?) { + unawaited(_confirmRoute(id)); + } + } + + Future _confirmRoute(String id) async { + try { + final repository = await ref.read( + nextcloudNotesRepositoryProvider.future, + ); + final pretty = await repository.confirmNoteWebRoute(id); + if (mounted) setState(() => _prettyUrls = pretty); + } on Object catch (error) { + if (mounted) setState(() => _routeFailure = error.toString()); + } + } + + @override + void dispose() { + _url.dispose(); + super.dispose(); + } + + void _complete(LinkDestination destination) { + final state = ref.read(workspaceControllerProvider); + Navigator.pop( + context, + state.workspace?.id == _workspaceId && state.activeBufferId == _bufferId + ? destination + : null, + ); + } + + LinkDestination? _destination(ReferenceTarget target, ReferenceIndex index) { + final state = ref.read(workspaceControllerProvider); + if (!target.document.available || + index.missingNoteIds.contains(target.document.serverId) || + state.workspace?.id != _workspaceId || + state.activeBufferId != _bufferId) { + return null; + } + if (index.server != null) { + if (target.document.serverId == null || _prettyUrls == null) return null; + // Nextcloud browser heading fragments are not a verified portable route. + if (target.anchor != null) return null; + return LinkDestination( + nextcloudNoteUrl( + index.server!, + target.document.serverId!, + pretty: _prettyUrls!, + ).toString(), + ); + } + final source = state.activeBuffer?.filePath; + final path = target.document.path; + if (source == null || path == null) return null; + final relative = p.equals(source, path) && target.anchor != null + ? '' + : p.relative(path, from: p.dirname(source)); + final project = state.workspace?.writersideProject; + final sourceModule = project?.topicOwnerForPath(source); + final origin = + sourceModule != null && + target.moduleId != null && + sourceModule.config.moduleName != target.moduleId + ? target.moduleId + : null; + final href = (sourceModule == null + ? relative + : p.equals(source, path) && target.anchor != null + ? '' + : origin == null + ? relative + : project?.modules + .expand((m) => m.topics) + .where((t) => p.equals(t.filePath, path)) + .firstOrNull + ?.fileName ?? + relative); + return LinkDestination( + href + .split('/') + .map( + (part) => Uri.encodeComponent( + part, + ).replaceAll('(', '%28').replaceAll(')', '%29'), + ) + .join('/') + + (target.anchor == null + ? '' + : '#${Uri.encodeComponent(target.anchor!)}'), + origin: origin, + ); + } + + @override + Widget build(BuildContext context) { + final data = ref.watch( + referenceTargetResultsProvider((text: _query, count: _limit)), + ); + final index = ref.watch(referenceIndexProvider).value; + ref.watch( + workspaceControllerProvider.select( + (s) => (s.workspace?.id, s.activeBufferId), + ), + ); + return BusyMarkDialogShell( + title: context.l10n.referenceChooseTarget, + maxWidth: 650, + actions: [ + BusyMarkDialogButton( + onPressed: () => Navigator.pop(context), + label: context.l10n.cancel, + ), + BusyMarkDialogButton( + onPressed: () => _complete(LinkDestination(_url.text)), + label: context.l10n.apply, + ), + ], + children: [ + if (_routeFailure != null) Text(_routeFailure!), + TextField( + controller: _url, + autofocus: true, + decoration: InputDecoration(labelText: context.l10n.source), + onSubmitted: (value) => _complete(LinkDestination(value)), + ), + const SizedBox(height: BusyMarkSpacing.md), + TextField( + decoration: InputDecoration(labelText: context.l10n.quickOpen), + onChanged: (query) => setState(() { + _query = query.toLowerCase(); + _limit = 100; + }), + ), + SizedBox( + height: 320, + child: data.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, stack) => Text(error.toString()), + data: (page) { + final targets = page.values; + return ListView.builder( + itemCount: + targets.length + (page.total > targets.length ? 1 : 0), + itemBuilder: (context, i) { + if (i == _limit) { + return TextButton( + onPressed: () => setState(() => _limit += 100), + child: Text(context.l10n.workspaceSearchShowMore), + ); + } + final target = targets[i]; + final destination = index == null + ? null + : _destination(target, index); + return ListTile( + title: Text(target.label ?? target.document.title), + subtitle: Text( + destination == null + ? index?.missingNoteIds.contains( + target.document.serverId, + ) == + true + ? context.l10n.referenceMissing + : index?.server == null || + !target.document.available + ? context.l10n.referenceUnavailable + : context.l10n.referenceNoPortableId + : '${destination.href}${destination.origin == null ? '' : ' / ${destination.origin}'}', + ), + enabled: destination != null, + onTap: () => _complete(destination!), + ); + }, + ); + }, + ), + ), + ], + ); + } +} + +class ReferencesPanel extends ConsumerStatefulWidget { + const ReferencesPanel({super.key}); + @override + ConsumerState createState() => _ReferencesPanelState(); +} + +class _ReferencesPanelState extends ConsumerState { + bool _incoming = false; + String? _anchor; + int _limit = 100; + String? _identity; + Future _navigate( + IndexedReference result, + bool target, { + bool source = false, + }) async { + final workspace = ref.read(workspaceControllerProvider).workspace; + final document = target ? result.target?.document : result.source; + if (document == null || workspace == null) return; + final controller = ref.read(workspaceControllerProvider.notifier); + final opened = document.localId != null + ? await controller.openNextcloudNote(document.localId!) + : await controller.openActiveFile(document.path!); + if (!mounted || + !opened || + ref.read(workspaceControllerProvider).workspace?.id != workspace.id) { + return; + } + final buffer = ref.read(workspaceControllerProvider).activeBuffer; + if (buffer == null || + (buffer.remoteNote?.localId ?? buffer.filePath) != document.identity) { + return; + } + var span = target ? result.target?.span : result.authored.occurrence; + if (referenceRevision(buffer.text) != document.revision) { + ref.read(referenceIndexProvider.notifier).refresh(); + // Refresh stale positions instead of selecting unrelated text. The user + // can navigate again when the refreshed occurrence is visible. + return; + } + if (span == null) return; + if (source) { + controller.updateActiveEditorMode(DocumentViewModePreference.source); + } + ref + .read(notesMatchNavigationProvider.notifier) + .navigate( + document.identity, + span.startOffset, + span.endOffset, + document.revision, + ); + } + + @override + Widget build(BuildContext context) { + final state = ref.watch(workspaceControllerProvider); + final identity = + state.activeBuffer?.remoteNote?.localId ?? state.activeBuffer?.filePath; + if (identity != _identity) { + _identity = identity; + _anchor = null; + _limit = 100; + } + final data = ref.watch( + referencePanelResultsProvider(( + identity: identity ?? '', + incoming: _incoming, + anchor: _anchor, + count: _limit, + )), + ); + final recovery = + ref.watch(refactoringRecoveryProvider).value ?? const []; + final account = state.workspace?.isRemote == true + ? ref.watch(nextcloudConnectionProvider).account + : null; + return Column( + children: [ + for (final journal in recovery) + TextButton( + onPressed: () async { + await reviewMarkdownRecovery(context, ref, journal); + if (mounted) ref.invalidate(refactoringRecoveryProvider); + }, + child: Text(context.l10n.notesRecovery), + ), + Text( + account == null + ? state.workspace?.filesystemRootPath ?? '' + : '${account.loginName} · ${account.server}', + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + if (state.workspace?.writersideProject case final project?) + Text( + '${project.activeModule?.config.moduleName ?? ''} / ${project.activeInstanceId ?? ''}', + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + SegmentedButton( + segments: [ + ButtonSegment( + value: true, + label: Text(context.l10n.referenceIncoming), + ), + ButtonSegment( + value: false, + label: Text(context.l10n.referenceOutgoing), + ), + ], + selected: {_incoming}, + onSelectionChanged: (v) => setState(() { + _incoming = v.single; + _limit = 100; + }), + ), + Expanded( + child: data.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, stack) => Column( + children: [ + Text(error.toString()), + TextButton( + onPressed: () => + ref.read(referenceIndexProvider.notifier).refresh(), + child: Text(context.l10n.tocTemplateRetry), + ), + ], + ), + data: (page) { + final headings = page.headings; + final selected = page.references; + if (!headings.any((h) => h.anchor == _anchor)) _anchor = null; + return Column( + children: [ + DropdownButton( + value: _anchor, + isExpanded: true, + hint: Text(context.l10n.referenceWholeDocument), + items: [ + DropdownMenuItem( + value: null, + child: Text(context.l10n.referenceWholeDocument), + ), + for (final h in headings) + DropdownMenuItem( + value: h.anchor, + child: Text(h.label ?? h.anchor!), + ), + ], + onChanged: (v) => setState(() { + _anchor = v; + _limit = 100; + }), + ), + Expanded( + child: ListView.builder( + itemCount: + selected.length + + (page.total > selected.length ? 1 : 0), + itemBuilder: (context, i) { + if (i == _limit) { + return TextButton( + onPressed: () => setState(() => _limit += 100), + child: Text(context.l10n.workspaceSearchShowMore), + ); + } + final result = selected[i]; + return ListTile( + title: Text( + _incoming + ? result.source.title + : result.target?.document.title ?? + result.authored.destination, + ), + subtitle: Text( + '${result.authored.label}\n${_resolutionLabel(context, result.resolution)}' + '${result.authored.moduleId == null ? '' : '\n${result.authored.moduleId} / ${result.authored.origin ?? ''}'}', + ), + onTap: () => + _navigate(result, _incoming ? false : true), + trailing: PopupMenuButton( + icon: const Icon(BusyMarkGlyphs.link), + onSelected: (target) => + _navigate(result, target, source: true), + itemBuilder: (context) => [ + PopupMenuItem( + value: false, + child: Text( + context.l10n.referenceSourceLocation, + ), + ), + if (result.target != null) + PopupMenuItem( + value: true, + child: Text( + context.l10n.referenceTargetLocation, + ), + ), + ], + ), + ); + }, + ), + ), + ], + ); + }, + ), + ), + ], + ); + } +} + +String _resolutionLabel(BuildContext context, ReferenceResolution status) => + switch (status) { + ReferenceResolution.resolved => context.l10n.referenceResolved, + ReferenceResolution.missing => context.l10n.referenceMissing, + ReferenceResolution.ambiguous => context.l10n.referenceAmbiguous, + ReferenceResolution.unavailable => context.l10n.referenceUnavailable, + ReferenceResolution.unsupported => context.l10n.referenceUnsupported, + }; + +final refactoringRecoveryProvider = FutureProvider>((ref) async { + final workspace = ref.watch(workspaceControllerProvider).workspace; + if (workspace?.filesystemRootPath == null) return const []; + final folder = Directory( + p.join(workspace!.rootPath, '.busymark-refactoring'), + ); + if (!await folder.exists()) return const []; + if (await FileSystemEntity.isLink(folder.path)) return const []; + return (await folder.list(followLinks: false).toList()) + .whereType() + .where((f) => p.extension(f.path) == '.json') + .map((f) => f.path) + .toList(); +}); diff --git a/lib/src/references/writerside_reference_adapter.dart b/lib/src/references/writerside_reference_adapter.dart new file mode 100644 index 00000000..2381ed06 --- /dev/null +++ b/lib/src/references/writerside_reference_adapter.dart @@ -0,0 +1,229 @@ +import '../writerside/writerside_document_resolver.dart'; +import '../writerside/writerside_project.dart'; +import '../writerside/writerside_document.dart'; +import '../writerside/writerside_schema.dart'; +import 'reference_index.dart'; +import 'markdown_references.dart'; + +// Adapts the established Writerside symbol index; there is no second parser. +void addWritersideReferences( + ReferenceIndex index, + WritersideProjectIndex writerside, + String? instanceId, +) { + final documents = index.documents, + targets = index.targets, + references = index.references; + final origins = writerside.modulesById; + String symbolLabel(WritersideSymbol symbol) { + final node = origins[symbol.moduleId]?.topics + .where((t) => t.filePath == symbol.filePath) + .firstOrNull + ?.document + .contentById(symbol.name) + ?.firstOrNull; + if (node is WritersideMarkdownBlockNode) return node.block.plainText; + if (node is WritersideElementNode) { + return node.attributes['title'] ?? symbol.name; + } + return symbol.name; + } + + for (final symbol in writerside.symbols.where( + (s) => { + WritersideSymbolKind.topic, + WritersideSymbolKind.element, + WritersideSymbolKind.snippet, + }.contains(s.kind), + )) { + final document = documents[symbol.filePath]; + if (document == null) continue; + final anchor = symbol.kind == WritersideSymbolKind.topic + ? null + : symbol.name; + if (anchor == null) { + targets.removeWhere( + (t) => + t.document.identity == document.identity && + t.anchor == null && + t.moduleId == null, + ); + } + if (!targets.any( + (t) => t.document.identity == document.identity && t.anchor == anchor, + )) { + targets.add( + ReferenceTarget( + document, + anchor: anchor, + label: symbolLabel(symbol), + span: symbol.span, + moduleId: symbol.moduleId, + scopeSpan: writerside.modulesById[symbol.moduleId]?.topics + .where((t) => t.filePath == symbol.filePath) + .firstOrNull + ?.document + .contentById(symbol.name) + ?.firstOrNull + ?.span, + ), + ); + } + } + for (final ref in writerside.references.where( + (r) => { + WritersideSymbolKind.topic, + WritersideSymbolKind.element, + WritersideSymbolKind.snippet, + }.contains(r.kind), + )) { + final document = documents[ref.filePath]; + if (document == null) continue; + final sourceModule = origins[ref.moduleId]; + final sourceTopic = sourceModule?.topics + .where((t) => t.filePath == ref.filePath) + .firstOrNull; + final sourceInstance = sourceModule?.instances + .where((i) => i.id == instanceId) + .firstOrNull; + final sourceNode = sourceTopic?.document + .walk() + .where( + (n) => + n.span.startOffset <= ref.span.startOffset && + n.span.endOffset >= ref.span.endOffset, + ) + .lastOrNull; + Map? dynamicLink; + if (ref.value.contains('%') && + sourceModule != null && + sourceTopic != null && + sourceNode != null && + sourceNode is WritersideElementNode && + sourceNode.semanticKind == WritersideSemanticKind.link) { + dynamicLink = const WritersideDocumentResolver().resolveAuthoredLink( + context: WritersideResolveContext( + module: sourceModule, + topic: sourceTopic, + instance: sourceInstance, + modulesByOrigin: origins, + ), + node: sourceNode, + destination: ref.value, + origin: ref.origin, + ); + } + final definitions = writerside.definitions( + dynamicLink?['resolved-destination'] ?? ref.value, + moduleId: ref.moduleId, + origin: ref.origin, + kind: ref.kind, + filePath: ref.filePath, + referenceOffset: ref.span.startOffset, + scopeReference: ref.scopeReference, + ); + final unique = { + for (final symbol in definitions) + '${symbol.filePath}:${symbol.kind == WritersideSymbolKind.topic ? '' : '${symbol.name}:${symbol.span?.startOffset}'}': + symbol, + }; + var resolution = unique.isEmpty + ? ReferenceResolution.missing + : unique.length > 1 + ? ReferenceResolution.ambiguous + : ReferenceResolution.resolved; + if (unique.isEmpty && + writerside.modulesById[ref.origin ?? ref.moduleId] + ?.isUnparsedTopicReference(ref.value.split('#').first) == + true) { + resolution = ReferenceResolution.unavailable; + } + ReferenceTarget? target; + var includeReference = ref.kind == WritersideSymbolKind.snippet; + if (unique.length == 1) { + final symbol = unique.values.single; + final targetDocument = documents[symbol.filePath]; + if (targetDocument != null) { + target = ReferenceTarget( + targetDocument, + anchor: symbol.kind == WritersideSymbolKind.topic + ? null + : symbol.name, + label: symbolLabel(symbol), + span: symbol.span, + moduleId: symbol.moduleId, + scopeSpan: origins[symbol.moduleId]?.topics + .where((t) => t.filePath == symbol.filePath) + .firstOrNull + ?.document + .contentById(symbol.name) + ?.firstOrNull + ?.span, + ); + } + final module = origins[ref.moduleId]; + final topic = module?.topics + .where((t) => t.filePath == ref.filePath) + .firstOrNull; + final instance = module?.instances + .where((i) => i.id == instanceId) + .firstOrNull; + final node = topic?.document + .walk() + .where( + (n) => + n.span.startOffset <= ref.span.startOffset && + n.span.endOffset >= ref.span.endOffset, + ) + .lastOrNull; + if (module != null && topic != null && node != null) { + includeReference = + node is WritersideElementNode && + node.semanticKind == WritersideSemanticKind.include; + final available = includeReference + ? const WritersideDocumentResolver().authoredIncludeAvailable( + context: WritersideResolveContext( + module: module, + topic: topic, + instance: instance, + modulesByOrigin: origins, + ), + include: node, + ) + : (dynamicLink ?? + const WritersideDocumentResolver().resolveAuthoredLink( + context: WritersideResolveContext( + module: module, + topic: topic, + instance: instance, + modulesByOrigin: origins, + ), + node: node, + destination: ref.value, + origin: ref.origin, + ))['resolved-available'] == + 'true'; + if (!available) resolution = ReferenceResolution.unavailable; + } + } + references.add( + IndexedReference( + source: document, + authored: AuthoredReference( + destination: ref.value, + kind: includeReference + ? AuthoredReferenceKind.include + : AuthoredReferenceKind.topic, + occurrence: ref.span, + destinationSpan: ref.span, + label: ref.sourceValue ?? ref.value, + origin: ref.origin, + moduleId: ref.moduleId, + ), + resolution: resolution, + target: target, + ), + ); + } + index.rebuildLookups(); +} diff --git a/lib/src/spellcheck/spelling_inline_projection.dart b/lib/src/spellcheck/spelling_inline_projection.dart index 9d6a2fe6..dbf7f6ba 100644 --- a/lib/src/spellcheck/spelling_inline_projection.dart +++ b/lib/src/spellcheck/spelling_inline_projection.dart @@ -157,6 +157,10 @@ List projectSpellingInlineRuns({ switch (inline.kind) { case BusyInlineKind.math: case BusyInlineKind.html: + case BusyInlineKind.writersideControl: + case BusyInlineKind.writersidePath: + case BusyInlineKind.writersideUiPath: + case BusyInlineKind.writersideShortcut: case BusyInlineKind.writersideVariable: case BusyInlineKind.unknown: barrier(); diff --git a/lib/src/workspace/presentation/settings_screen.dart b/lib/src/workspace/presentation/settings_screen.dart index d35b45a7..4e107f1c 100644 --- a/lib/src/workspace/presentation/settings_screen.dart +++ b/lib/src/workspace/presentation/settings_screen.dart @@ -684,6 +684,7 @@ class _SettingsScreenState extends ConsumerState { _selectPage(SettingsPage.appearance); case BusyMarkMainMenuAction.keyboardShortcuts: showBusyMarkKeyboardShortcutsDialog(context); + case BusyMarkMainMenuAction.quickOpen: case BusyMarkMainMenuAction.commandPalette: return; case BusyMarkMainMenuAction.syntaxReference: diff --git a/lib/src/workspace/presentation/welcome_screen.dart b/lib/src/workspace/presentation/welcome_screen.dart index 22189c42..75ef3f7d 100644 --- a/lib/src/workspace/presentation/welcome_screen.dart +++ b/lib/src/workspace/presentation/welcome_screen.dart @@ -325,6 +325,7 @@ class _WelcomeScreenState extends ConsumerState { context.go(settingsLocation(SettingsReturnTarget.welcome)); case BusyMarkMainMenuAction.keyboardShortcuts: showBusyMarkKeyboardShortcutsDialog(context); + case BusyMarkMainMenuAction.quickOpen: case BusyMarkMainMenuAction.commandPalette: return; case BusyMarkMainMenuAction.syntaxReference: diff --git a/lib/src/workspace/presentation/workspace_screen.dart b/lib/src/workspace/presentation/workspace_screen.dart index 7aa58be1..d420f961 100644 --- a/lib/src/workspace/presentation/workspace_screen.dart +++ b/lib/src/workspace/presentation/workspace_screen.dart @@ -1,3 +1,9 @@ +import '../../references/markdown_move_ui.dart'; +import '../../nextcloud_notes/presentation/notes_workspace_ui.dart'; +import '../../nextcloud_notes/data/server_uri.dart'; +import '../../references/reference_ui.dart'; +import '../../references/reference_controller.dart'; +import '../../nextcloud_notes/domain/notes_search.dart'; import '../../assets/provider_asset_ingestion_service.dart'; import '../../assets/document_media_context.dart'; import '../../nextcloud_notes/application/nextcloud_connection.dart'; @@ -68,6 +74,7 @@ import '../../editor/source/source_document.dart'; import '../../editor/source/source_editor.dart'; import '../../editor/source/source_search.dart'; import '../../editor/wysiwyg/wysiwyg_editor.dart'; +import '../../editor/wysiwyg/writerside_editing_adapter.dart'; import '../../editor/wysiwyg/wysiwyg_session_state.dart'; import '../../editor/writerside_video_view.dart'; import '../../feedback/presentation/feedback_dialog.dart'; @@ -628,9 +635,13 @@ class _SearchNavigationTarget { required this.endOffset, required this.query, required this.request, + this.localId, + this.digest, }); - final String filePath; + final String? filePath; + final String? localId; + final String? digest; final int line; final int startOffset; final int endOffset; @@ -1258,6 +1269,11 @@ class WorkspaceScreen extends ConsumerWidget { } void _openSearch(WidgetRef ref) { + if (ref.read(workspaceControllerProvider).workspace?.isRemote == true) { + final target = ref.context; + unawaited(showNotesSearch(target, ref)); + return; + } final search = ref.read(_workspaceSearchProvider); ref.read(_headerSearchFocusRequestProvider.notifier).request(); if (!search.active) { @@ -1412,6 +1428,7 @@ class WorkspaceScreen extends ConsumerWidget { context.go(settingsLocation(SettingsReturnTarget.workspace)); case BusyMarkMainMenuAction.keyboardShortcuts: showBusyMarkKeyboardShortcutsDialog(context); + case BusyMarkMainMenuAction.quickOpen: case BusyMarkMainMenuAction.commandPalette: return; case BusyMarkMainMenuAction.syntaxReference: @@ -2334,6 +2351,9 @@ class _SidebarState extends ConsumerState<_Sidebar> { final repositoryInfo = gitState.attachedWorkspace?.id == widget.workspace.id ? gitState.repositoryInfo : null; + ref.listen(referencePanelRequestProvider, (_, next) { + _selectTab(_SidebarTab.references, tabs); + }); ref.listen<_SidebarTab?>(_sidebarShortcutRequestProvider, (_, next) { if (next == null) { return; @@ -2446,6 +2466,7 @@ class _SidebarState extends ConsumerState<_Sidebar> { .target ?.requestEditorFocus(), ), + _SidebarTab.references => const ReferencesPanel(), _SidebarTab.localHistory => LocalHistoryPanel( focusSearchRequest: ref.watch( localHistoryFindRequestProvider, @@ -3068,7 +3089,16 @@ class _SidebarState extends ConsumerState<_Sidebar> { } } -enum _SidebarTab { notes, files, toc, outline, git, localHistory, clipboard } +enum _SidebarTab { + notes, + files, + toc, + outline, + git, + localHistory, + clipboard, + references, +} int _preferredSidebarTabIndex(Workspace workspace) { final tabs = _sidebarTabsFor(workspace.kind); @@ -3096,16 +3126,19 @@ List<_SidebarTab> _sidebarTabsFor(WorkspaceKind kind) { _SidebarTab.outline, _SidebarTab.localHistory, _SidebarTab.clipboard, + _SidebarTab.references, ], WorkspaceKind.untitledMarkdown => const [ _SidebarTab.outline, _SidebarTab.localHistory, _SidebarTab.clipboard, + _SidebarTab.references, ], WorkspaceKind.singleMarkdown => const [ _SidebarTab.outline, _SidebarTab.localHistory, _SidebarTab.clipboard, + _SidebarTab.references, ], WorkspaceKind.markdownFolder => const [ _SidebarTab.files, @@ -3113,6 +3146,7 @@ List<_SidebarTab> _sidebarTabsFor(WorkspaceKind kind) { _SidebarTab.git, _SidebarTab.localHistory, _SidebarTab.clipboard, + _SidebarTab.references, ], WorkspaceKind.writersideModule => const [ _SidebarTab.files, @@ -3121,6 +3155,7 @@ List<_SidebarTab> _sidebarTabsFor(WorkspaceKind kind) { _SidebarTab.git, _SidebarTab.localHistory, _SidebarTab.clipboard, + _SidebarTab.references, ], }; } @@ -3134,6 +3169,7 @@ String _sidebarTabLabel(BuildContext context, _SidebarTab tab) { _SidebarTab.git => context.l10n.git, _SidebarTab.localHistory => context.l10n.localHistory, _SidebarTab.clipboard => context.l10n.clipboardHistory, + _SidebarTab.references => context.l10n.references, }; } @@ -3146,6 +3182,7 @@ IconData _sidebarTabIcon(_SidebarTab tab, TextDirection direction) { _SidebarTab.git => BusyMarkGlyphs.branch, _SidebarTab.localHistory => BusyMarkGlyphs.sidebarLocalHistory, _SidebarTab.clipboard => BusyMarkGlyphs.copy, + _SidebarTab.references => BusyMarkGlyphs.link, }; } @@ -3161,6 +3198,7 @@ String? _sidebarTabShortcut(BuildContext context, _SidebarTab tab) { _SidebarTab.git => BusyMarkCommandIds.sidebarGit, _SidebarTab.localHistory => BusyMarkCommandIds.localHistory, _SidebarTab.clipboard => BusyMarkCommandIds.clipboardHistory, + _SidebarTab.references => BusyMarkCommandIds.references, }; return commands[id]?.shortcut?.label; } @@ -4741,9 +4779,21 @@ class _FilesTabState extends ConsumerState<_FilesTab> { !await saveOrConfirmSafeToChangeActiveFile(context, ref)) { return false; } + if (!mounted) return false; + final plan = await reviewMarkdownMove( + this.context, + ref, + path, + p.join(p.dirname(path), newName), + ); + if (!mounted || !plan.proceed) return false; return ref .read(workspaceControllerProvider.notifier) - .renameWorkspaceEntity(path, newName); + .renameWorkspaceEntity( + path, + newName, + reviewedPlan: plan.plan, + ); }()); if (renamed) { setState(() => _cutEntry = null); @@ -4760,9 +4810,21 @@ class _FilesTabState extends ConsumerState<_FilesTab> { return; } final directory = isFolder ? path : p.dirname(path); + if (!mounted) return; + final plan = await reviewMarkdownMove( + this.context, + ref, + entry.path, + p.join(directory, p.basename(entry.path)), + ); + if (!mounted || !plan.proceed) return; final moved = await ref .read(workspaceControllerProvider.notifier) - .moveWorkspaceEntity(entry.path, directory); + .moveWorkspaceEntity( + entry.path, + directory, + reviewedPlan: plan.plan, + ); if (moved) { setState(() => _cutEntry = null); _expandedPaths.addAll(_directoryAncestorPaths(directory)); @@ -10259,6 +10321,18 @@ List _activeDocumentOutline(WorkspaceState state) { return preview.outline; } final buffer = state.activeBuffer; + if (workspace != null && + buffer != null && + resolveWorkspaceDocumentContext(workspace, buffer).kind == + DocumentKind.writersideXmlTopic) { + return const WritersideEditingAdapter() + .parseXml( + filePath: buffer.filePath ?? workspace.activeFilePath ?? '', + source: buffer.text, + ) + ?.outline ?? + const []; + } final markdown = workspace?.markdown; if (workspace == null || markdown == null) return const []; if (buffer == null) { @@ -11976,6 +12050,7 @@ class _EditorPreviewSplitState extends ConsumerState<_EditorPreviewSplit> { String? _cachedWysiwygPath; String? _cachedWysiwygSource; MarkdownMode? _cachedWysiwygMode; + DocumentKind? _cachedWysiwygKind; String? _wysiwygScrollHeadingId; String? _wysiwygScrollBlockId; String? _wysiwygSearchQuery; @@ -12125,10 +12200,41 @@ class _EditorPreviewSplitState extends ConsumerState<_EditorPreviewSplit> { } _scrollToOutlineTarget(next); }); + ref.listen(notesMatchNavigationProvider, (previous, next) { + if (next == null || + next.request == previous?.request || + (widget.state.activeBuffer?.remoteNote?.localId ?? + widget.state.activeBuffer?.filePath) != + next.localId) { + return; + } + final source = widget.state.activeBuffer!.text; + if (notesSearchDigest(source) != next.digest) return; + final line = SourceDocument( + fullText: source, + ).lineIndex.lineNumberAtOffset(next.start); + final old = ref.read(_searchNavigationTargetProvider); + ref + .read(_searchNavigationTargetProvider.notifier) + .set( + _SearchNavigationTarget( + filePath: null, + localId: next.localId, + digest: next.digest, + line: line, + startOffset: next.start, + endOffset: next.end, + query: '', + request: (old?.request ?? 0) + 1, + ), + ); + }); final searchTarget = ref.watch(_searchNavigationTargetProvider); if (searchTarget != null && - ref.read(_workspaceSearchProvider).active && - ref.read(_workspaceSearchProvider).query == searchTarget.query && + (searchTarget.localId != null || + (ref.read(_workspaceSearchProvider).active && + ref.read(_workspaceSearchProvider).query == + searchTarget.query)) && searchTarget.request != _lastSearchNavigationRequest) { _scrollToSearchTarget(searchTarget); } @@ -12310,6 +12416,17 @@ class _EditorPreviewSplitState extends ConsumerState<_EditorPreviewSplit> { Expanded( key: const ValueKey('document-wysiwyg-pane'), child: BusyMarkWysiwygEditor( + onChooseLink: (initial) => + showReferenceLinkChooser( + context, + initialDestination: initial, + ), + writersideProjectIndex: widget + .state + .workspace + ?.writersideProject + ?.index, + writersideModuleId: _activeWritersideModuleId(), key: _wysiwygEditorKey, document: wysiwygDocument, documentId: activeBuffer?.id, @@ -12487,6 +12604,8 @@ class _EditorPreviewSplitState extends ConsumerState<_EditorPreviewSplit> { Expanded( key: const ValueKey('document-source-pane'), child: BusyMarkSourceEditor( + onChooseLink: () => + showReferenceLinkChooser(context), key: _sourceEditorKey, text: widget.state.activeText, language: _sourceSyntaxLanguage( @@ -13674,6 +13793,7 @@ class _EditorPreviewSplitState extends ConsumerState<_EditorPreviewSplit> { _cachedWysiwygPath = document.filePath; _cachedWysiwygSource = document.source; _cachedWysiwygMode = context.markdownMode; + _cachedWysiwygKind = context.kind; } void _clearWysiwygCache() { @@ -13682,6 +13802,7 @@ class _EditorPreviewSplitState extends ConsumerState<_EditorPreviewSplit> { _cachedWysiwygPath = null; _cachedWysiwygSource = null; _cachedWysiwygMode = null; + _cachedWysiwygKind = null; } String? _activeEditorPath() { @@ -13920,7 +14041,13 @@ class _EditorPreviewSplitState extends ConsumerState<_EditorPreviewSplit> { ref.read(_searchNavigationTargetProvider)?.request != target.request || _lastSearchNavigationRequest == target.request || - target.filePath != widget.state.activeBuffer?.filePath) { + (target.localId != null + ? target.localId != + (widget.state.activeBuffer?.remoteNote?.localId ?? + widget.state.activeBuffer?.filePath) || + notesSearchDigest(widget.state.activeBuffer?.text ?? '') != + target.digest + : target.filePath != widget.state.activeBuffer?.filePath)) { return; } _lastSearchNavigationRequest = target.request; @@ -14169,7 +14296,24 @@ class _EditorPreviewSplitState extends ConsumerState<_EditorPreviewSplit> { bool _canUseWysiwyg(Workspace? workspace) { final kind = _activeDocumentKind(workspace); - return kind?.supportsAiMarkdownEditing ?? false; + return kind?.supportsVisualEditing ?? false; + } + + String? _activeWritersideModuleId() { + final workspace = widget.state.workspace; + final buffer = widget.state.activeBuffer; + if (workspace == null || buffer == null) return null; + final module = resolveWorkspaceDocumentContext( + workspace, + buffer, + ).writersideModule; + if (module == null) return null; + for (final entry + in workspace.writersideProject?.index.modulesById.entries ?? + const Iterable.empty()) { + if (p.equals(entry.value.rootPath, module.rootPath)) return entry.key; + } + return null; } BusyDocument? _wysiwygDocument() { @@ -14183,9 +14327,19 @@ class _EditorPreviewSplitState extends ConsumerState<_EditorPreviewSplit> { if (_cachedWysiwygBufferId == buffer.id && _cachedWysiwygPath == activePath && _cachedWysiwygSource == buffer.text && - _cachedWysiwygMode == context.markdownMode) { + _cachedWysiwygMode == context.markdownMode && + _cachedWysiwygKind == context.kind) { return _cachedWysiwygDocument; } + if (context.kind == DocumentKind.writersideXmlTopic) { + final document = const WritersideEditingAdapter().parseXml( + filePath: activePath, + source: buffer.text, + ); + _clearWysiwygCache(); + if (document != null) _cacheWysiwygDocument(buffer.id, document); + return document; + } final currentMarkdown = workspace.markdown; if (currentMarkdown != null && p.equals(currentMarkdown.filePath, activePath) && @@ -14197,6 +14351,7 @@ class _EditorPreviewSplitState extends ConsumerState<_EditorPreviewSplit> { _cachedWysiwygPath = activePath; _cachedWysiwygSource = buffer.text; _cachedWysiwygMode = context.markdownMode; + _cachedWysiwygKind = context.kind; return document; } try { @@ -14214,6 +14369,7 @@ class _EditorPreviewSplitState extends ConsumerState<_EditorPreviewSplit> { _cachedWysiwygPath = activePath; _cachedWysiwygSource = buffer.text; _cachedWysiwygMode = context.markdownMode; + _cachedWysiwygKind = context.kind; return document; } on Object { return null; @@ -16327,6 +16483,27 @@ Future _openPreviewLink( } final uri = parseSchemedUri(target); final remoteState = ref.read(workspaceControllerProvider); + final accountId = remoteState.workspace?.nextcloudAccountId; + final account = accountId == null + ? null + : ref + .read(nextcloudNotesRepositoryProvider) + .value + ?.accountById(accountId); + final linkedId = account == null + ? null + : nextcloudNoteId(account.server, target); + if (linkedId != null) { + final opened = await ref + .read(workspaceControllerProvider.notifier) + .openNextcloudNoteLink(linkedId); + if (opened && context.mounted && uri?.hasFragment == true) { + _navigatePreviewAnchor(context, ref, '', uri!.fragment); + } else if (!opened && context.mounted) { + _showPreviewLinkMessage(context, context.l10n.couldNotOpenTarget(target)); + } + return; + } if (remoteState.workspace?.isRemote == true && (uri == null || !isLaunchableExternalUri(uri))) { if (target.startsWith('#')) { diff --git a/lib/src/workspace/workspace_controller.dart b/lib/src/workspace/workspace_controller.dart index 4bbdb20e..a534031d 100644 --- a/lib/src/workspace/workspace_controller.dart +++ b/lib/src/workspace/workspace_controller.dart @@ -1,3 +1,5 @@ +import '../references/markdown_move_service.dart'; +import '../nextcloud_notes/application/notes_navigation.dart'; import 'dart:async'; import 'dart:io'; import 'dart:math' as math; @@ -25,6 +27,7 @@ import '../local_history/local_history_models.dart'; import '../nextcloud_notes/domain/notes_models.dart'; import '../nextcloud_notes/domain/notes_conflict.dart'; import '../nextcloud_notes/application/notes_repository.dart'; +import '../nextcloud_notes/application/notes_sync_coordinator.dart'; import '../nextcloud_notes/application/nextcloud_connection.dart'; import '../nextcloud_notes/application/notes_media.dart'; import '../export/markdown_copy_export_service.dart'; @@ -230,10 +233,9 @@ class WorkspaceController extends Notifier { late LocalHistoryController _localHistory; NotesRepository? _notesRepository; StreamSubscription? _notesSubscription; - final _remoteSyncTasks = >{}; - final _remoteSyncRequested = {}; - final _remoteRetryTimers = {}; - final _remoteRetryCounts = {}; + NotesSyncCoordinator? _notesCoordinator; + bool _notesApplicationVisible = true; + bool _notesWorkspaceActive = true; final _remoteDurabilityWrites = {}; var _restoringLocalHistorySession = false; StreamSubscription? _fileMonitorSubscription; @@ -361,9 +363,7 @@ class WorkspaceController extends Notifier { _restoredRecoveryOwnerIdsByBuffer.clear(); unawaited(_fileMonitorSubscription?.cancel()); unawaited(_notesSubscription?.cancel()); - for (final timer in _remoteRetryTimers.values) { - timer.cancel(); - } + _notesCoordinator?.dispose(); }); return const WorkspaceState(); } @@ -452,6 +452,12 @@ class WorkspaceController extends Notifier { await repository.initialize(); if (!ref.mounted) throw StateError('Workspace closed'); _notesRepository = repository; + _notesCoordinator = NotesSyncCoordinator( + repository, + onError: (error, stack) => _reportNextcloudFailure(error), + ); + _updateNotesCoordinator(); + listenSelf((previous, next) => _updateNotesCoordinator()); bool bindingGuard(String localId, Set ids) => !state.documentBuffers.any( (b) => @@ -670,7 +676,11 @@ class WorkspaceController extends Notifier { ); } _schedulePersistence(); - _scheduleRemoteSync(accountId); + unawaited( + _notesCoordinator + ?.request(NotesSyncTrigger.opening) + .catchError((Object error) => _reportNextcloudFailure(error)), + ); return true; } on Object catch (error) { if (ref.mounted) { @@ -717,6 +727,36 @@ class WorkspaceController extends Notifier { return result; } + Future openNextcloudNoteLink(int serverId) async { + final workspace = state.workspace; + final accountId = workspace?.nextcloudAccountId; + if (accountId == null) return false; + try { + final repository = await _ensureNotesRepository(); + final note = await repository.linkedNote(accountId, serverId); + if (!ref.mounted || + state.workspace?.id != workspace?.id || + state.workspace?.nextcloudAccountId != accountId) { + return false; + } + if (!await openNextcloudNote(note.localId)) return false; + // Cached navigation works offline. Refresh through the existing repository + // boundary, without a second synchronization coordinator or polling loop. + unawaited( + repository.refreshLinkedNote(accountId, serverId).catchError(( + Object error, + ) { + _reportNextcloudFailure(error); + return note; + }), + ); + return true; + } on Object catch (error) { + _reportNextcloudFailure(error); + return false; + } + } + Future createNextcloudNote({ String title = 'New note', String category = '', @@ -841,118 +881,156 @@ class WorkspaceController extends Notifier { } } - void _scheduleRemoteSync(String accountId, {bool automaticRetry = false}) { + void _updateNotesCoordinator() { + _notesCoordinator?.setActiveAccount( + _notesWorkspaceActive ? state.workspace?.nextcloudAccountId : null, + ); + _notesCoordinator?.setVisible(_notesApplicationVisible); + _notesRepository?.setDisplayedMediaNotes([ + if (state.activeBuffer?.remoteNote?.localId case final id?) id, + ]); + } + + void setNotesApplicationVisible(bool visible) { + _notesApplicationVisible = visible; + _notesCoordinator?.setVisible(visible); + } + + void setNotesWorkspaceActive(bool active) { + _notesWorkspaceActive = active; + _updateNotesCoordinator(); + if (active) _notesCoordinator?.focused(); + } + + void refreshNotesOnFocus() => _notesCoordinator?.focused(); + void _scheduleRemoteSync(String accountId) { if (!ref.mounted) return; - _remoteRetryTimers.remove(accountId)?.cancel(); - if (!automaticRetry) _remoteRetryCounts.remove(accountId); - _remoteSyncRequested.add(accountId); - if (_remoteSyncTasks.containsKey(accountId)) return; - final task = () async { - do { - _remoteSyncRequested.remove(accountId); - try { - await (await _ensureNotesRepository()).synchronize(accountId); - } on Object catch (error) { - if (ref.mounted && state.workspace?.nextcloudAccountId == accountId) { - state = state.copyWith( - message: WorkspaceMessage( - WorkspaceMessageCode.saveFailed, - error: error, - ), - ); - } - } - // An edit committed during the request schedules one more pass. Network - // failures do not request a pass, so this cannot spin on server locks. - } while (ref.mounted && _remoteSyncRequested.contains(accountId)); - _remoteSyncTasks.remove(accountId); - _scheduleRemoteRetry(accountId); - }(); - _remoteSyncTasks[accountId] = task; - unawaited(task); - } - - void _scheduleRemoteRetry(String accountId) { - if (!ref.mounted || state.workspace?.nextcloudAccountId != accountId) { - return; - } - final repository = _notesRepository; - if (repository == null) return; - final accountError = repository.accountError(accountId); - final retryable = - accountError?.code == NotesFailureCode.network || - accountError?.code == NotesFailureCode.server || - repository.notes.any( - (note) => - note.accountId == accountId && - note.hasPendingChanges && - { - NoteSyncState.offline, - NoteSyncState.locked, - NoteSyncState.pending, - }.contains(note.syncState), - ); - if (!retryable) { - _remoteRetryCounts.remove(accountId); - return; - } - final attempt = _remoteRetryCounts[accountId] ?? 0; - // Six delayed attempts, then explicit Refresh remains available. Creation - // uncertainty, conflicts, keyring/authentication and forbidden errors never - // trigger an automatic retry. - if (attempt >= 6) return; - _remoteRetryCounts[accountId] = attempt + 1; - _remoteRetryTimers[accountId] = Timer( - Duration(seconds: math.min(300, 5 * (1 << attempt))), - () { - _remoteRetryTimers.remove(accountId); - if (ref.mounted && state.workspace?.nextcloudAccountId == accountId) { - _scheduleRemoteSync(accountId, automaticRetry: true); - } - }, + _notesCoordinator?.setActiveAccount( + _notesWorkspaceActive ? state.workspace?.nextcloudAccountId : null, + ); + _notesCoordinator?.setVisible(_notesApplicationVisible); + unawaited( + _notesCoordinator + ?.request(NotesSyncTrigger.localChange) + .catchError((Object error) => _reportNextcloudFailure(error)), ); } Future refreshNextcloudNotes() async { - final account = state.workspace?.nextcloudAccountId; - if (account != null) { - _remoteRetryTimers.remove(account)?.cancel(); - _remoteRetryCounts.remove(account); - await (await _ensureNotesRepository()).synchronize(account); - _scheduleRemoteRetry(account); + if (state.workspace?.nextcloudAccountId == null) return; + await _ensureNotesRepository(); + _updateNotesCoordinator(); + await _notesCoordinator?.request(NotesSyncTrigger.manual); + } + + Future retryRejectedNextcloudNote(String localId) async { + try { + final repository = await _ensureNotesRepository(); + await repository.retryRejectedNote(localId); + final note = repository.noteById(localId); + if (note != null) _scheduleRemoteSync(note.accountId); + } on Object catch (error) { + _reportNextcloudFailure(error); } } Future updateNextcloudNoteMetadata( String localId, { + NotesMetadataSnapshot? snapshot, String? title, String? category, bool? favorite, }) async { try { final repository = await _ensureNotesRepository(); + final beforeRevision = repository.noteById(localId)?.revision; final buffer = state.documentBuffers .where((b) => b.remoteNote?.localId == localId) .firstOrNull; if (buffer?.isDirty == true && - !await _saveRemoteBufferSnapshot(buffer!)) { + !await _saveRemoteBufferSnapshot(buffer!, scheduleSync: false)) { return; } final note = repository.noteById(localId); if (note == null) return; - await repository.save( - localId, - content: note.content, + final updated = await repository.patchMetadata( + snapshot ?? repository.metadataSnapshot(localId), title: title, category: category, favorite: favorite, ); - _scheduleRemoteSync(note.accountId); + if (updated.revision != beforeRevision) { + _scheduleRemoteSync(note.accountId); + } } on Object catch (error) { _reportNextcloudFailure(error); } } + Future> batchNextcloudMetadata( + List reviewed, { + String? category, + bool? favorite, + }) async { + final repository = await _ensureNotesRepository(); + final eligible = []; + final failed = []; + for (final snapshot in reviewed) { + final buffer = state.documentBuffers + .where((b) => b.remoteNote?.localId == snapshot.note.localId) + .firstOrNull; + if (buffer?.isDirty == true && + !await _saveRemoteBufferSnapshot(buffer!, scheduleSync: false)) { + failed.add( + NotesBatchOutcome( + snapshot.note.localId, + snapshot.note.title, + NotesBatchStatus.failed, + 'Editor save failed.', + ), + ); + } else { + eligible.add(snapshot); + } + } + final outcomes = await repository.patchMetadataBatch( + eligible, + category: category, + favorite: favorite, + ); + for (final account in eligible.map((s) => s.note.accountId).toSet()) { + _scheduleRemoteSync(account); + } + return [...outcomes, ...failed]; + } + + Future?> captureNextcloudExport(Set ids) async { + final accountId = state.workspace?.nextcloudAccountId; + if (accountId == null) return null; + for (final id in ids) { + final buffer = state.documentBuffers + .where((b) => b.remoteNote?.localId == id) + .firstOrNull; + if (buffer?.isDirty == true && + !await _saveRemoteBufferSnapshot(buffer!, scheduleSync: false)) { + return null; + } + } + if (state.workspace?.nextcloudAccountId != accountId || + state.documentBuffers.any( + (b) => ids.contains(b.remoteNote?.localId) && b.isDirty, + )) { + return null; + } + return (await _ensureNotesRepository()).captureLocalSnapshot( + accountId, + ids, + ); + } + + void synchronizeImportedNotes(String accountId) => + _scheduleRemoteSync(accountId); + Future deleteNextcloudNote(String localId) async { try { final repository = await _ensureNotesRepository(); @@ -1075,6 +1153,30 @@ class WorkspaceController extends Notifier { } } + Future recoverDeletedNextcloudNote( + String localId, { + String? content, + }) async { + try { + final repository = await _ensureNotesRepository(); + final original = repository.noteById(localId); + if (original == null || + state.workspace?.nextcloudAccountId != original.accountId) { + return false; + } + final recovered = await repository.recoverAsNew( + localId, + content: content, + ); + final opened = await openNextcloudNote(recovered.localId); + _scheduleRemoteSync(original.accountId); + return opened; + } on Object catch (error) { + _reportNextcloudFailure(error); + return false; + } + } + Future recoverNextcloudHistoryRevision({ required LocalHistoryDocument document, required LocalHistoryRevision revision, @@ -1110,9 +1212,7 @@ class WorkspaceController extends Notifier { _cancelPendingDerivedRefresh(); _cancelAllAutoSaves(); _invalidateActiveDocumentOperations(); - _remoteSyncRequested.remove(accountId); - _remoteRetryTimers.remove(accountId)?.cancel(); - _remoteRetryCounts.remove(accountId); + _notesCoordinator?.removeAccount(accountId); for (final buffer in state.documentBuffers) { await _localHistory.handleBufferClosed(buffer.id, historySettled: true); } @@ -4107,10 +4207,159 @@ class WorkspaceController extends Notifier { return created; } - Future renameWorkspaceEntity(String path, String newName) async { + Future prepareMarkdownMove( + String source, + String destination, + ) async { + final workspace = state.workspace; + if (workspace == null || workspace.kind != WorkspaceKind.markdownFolder) { + return null; + } + _requireCleanAffectedFiles( + workspace, + workspace.files.map((f) => f.absolutePath), + ); + return MarkdownMoveService( + workspaceService: _service, + ).prepare(workspace, source, destination); + } + + Future recoverMarkdownMove(MarkdownRecoveryPlan plan) { + return _runWorkspaceFileOperation((workspace) async { + if (workspace.id != state.workspace?.id || + !p.equals(workspace.rootPath, plan.root)) { + throw StateError('Recovery workspace changed.'); + } + void validateBuffers() => _requireCleanAffectedFiles( + workspace, + workspace.files.map((f) => f.absolutePath), + ); + validateBuffers(); + final service = MarkdownMoveService(workspaceService: _service); + for (final file in plan.files) { + if (!await _localHistory.capturePath( + path: file.path, + text: file.load.text, + format: file.load.format, + reason: LocalHistoryCaptureReason.beforeRestore, + requireProtection: true, + )) { + throw StateError('Local History could not protect recovery.'); + } + } + if (!plan.moved) { + await service.recoverReviewed(plan, validateBuffers: validateBuffers); + return state.activeBuffer?.filePath; + } + final active = state.activeBuffer?.filePath; + final transitions = _beginLocalHistoryPathTransitions( + plan.destination, + plan.source, + ); + String? historyId; + var committed = false; + try { + await _localHistory.runStagedPathRemap( + sourcePath: plan.destination, + destinationPath: plan.source, + filesystemOperation: () async { + await service.recoverReviewed( + plan, + validateBuffers: validateBuffers, + ); + return plan.source; + }, + didCommit: (_) => true, + onOperationStaged: (id) => historyId = id, + ); + committed = true; + _remapOpenWorkspacePaths(workspace, plan.destination, plan.source); + await _localHistory.acknowledgeStagedPathOperation(historyId); + return _remapMovedPath(active, plan.destination, plan.source); + } finally { + if (!committed) { + await _localHistory.cancelStagedPathOperation(historyId); + } + await _finishLocalHistoryPathTransitions( + transitions, + committed: committed, + ); + } + }); + } + + Future _protectMarkdownMove( + Workspace workspace, + String source, + String destination, + MarkdownMovePlan? reviewed, + ) async { + if (workspace.kind != WorkspaceKind.markdownFolder || + p.equals(source, destination)) { + return null; + } + final service = MarkdownMoveService(workspaceService: _service); + final plan = + reviewed ?? await service.prepare(workspace, source, destination); + if (plan.workspaceId != workspace.id || + !p.equals(plan.source, source) || + !p.equals(plan.destination, destination)) { + throw StateError('The reviewed operation changed.'); + } + if (reviewed == null && plan.files.isNotEmpty) { + throw StateError('Review the affected links before moving this file.'); + } + void validateBuffers() => + _requireCleanAffectedFiles(workspace, plan.snapshots.keys); + validateBuffers(); + for (final file in plan.files) { + if (!await _localHistory.capturePath( + path: file.path, + text: file.load.text, + format: file.load.format, + reason: LocalHistoryCaptureReason.beforeRestore, + requireProtection: true, + )) { + throw StateError( + 'Local History could not protect the reviewed changes.', + ); + } + } + return plan; + } + + Future _applyMarkdownMove( + Workspace workspace, + String source, + String destination, + MarkdownMovePlan? plan, + Future Function() move, + ) async { + if (plan == null) return move(); + if (state.workspace?.id != plan.workspaceId) { + throw StateError('The reviewed workspace changed.'); + } + void validateBuffers() => + _requireCleanAffectedFiles(workspace, plan.snapshots.keys); + return MarkdownMoveService( + workspaceService: _service, + ).apply(plan, move: move, validateBuffers: validateBuffers); + } + + Future renameWorkspaceEntity( + String path, + String newName, { + MarkdownMovePlan? reviewedPlan, + }) async { final activeFilePath = state.workspace?.activeFilePath; return _runWorkspaceFileOperation((workspace) async { final expectedTarget = p.normalize(p.join(p.dirname(path), newName)); + final protectedPlan = await _protectMarkdownMove( + workspace, + path, + expectedTarget, + reviewedPlan, + ); final transitions = _beginLocalHistoryPathTransitions( path, expectedTarget, @@ -4121,8 +4370,13 @@ class WorkspaceController extends Notifier { final target = await _localHistory.runStagedPathRemap( sourcePath: path, destinationPath: expectedTarget, - filesystemOperation: () => - _service.renameEntity(workspace, path, newName), + filesystemOperation: () => _applyMarkdownMove( + workspace, + path, + expectedTarget, + protectedPlan, + () => _service.renameEntity(workspace, path, newName), + ), didCommit: (_) => true, onOperationStaged: (value) => historyOperationId = value, ); @@ -4144,13 +4398,20 @@ class WorkspaceController extends Notifier { Future moveWorkspaceEntity( String sourcePath, - String targetDirectoryPath, - ) async { + String targetDirectoryPath, { + MarkdownMovePlan? reviewedPlan, + }) async { final activeFilePath = state.workspace?.activeFilePath; return _runWorkspaceFileOperation((workspace) async { final expectedTarget = p.normalize( p.join(targetDirectoryPath, p.basename(sourcePath)), ); + final protectedPlan = await _protectMarkdownMove( + workspace, + sourcePath, + expectedTarget, + reviewedPlan, + ); final transitions = _beginLocalHistoryPathTransitions( sourcePath, expectedTarget, @@ -4161,8 +4422,14 @@ class WorkspaceController extends Notifier { final target = await _localHistory.runStagedPathRemap( sourcePath: sourcePath, destinationPath: expectedTarget, - filesystemOperation: () => - _service.moveEntity(workspace, sourcePath, targetDirectoryPath), + filesystemOperation: () => _applyMarkdownMove( + workspace, + sourcePath, + expectedTarget, + protectedPlan, + () => + _service.moveEntity(workspace, sourcePath, targetDirectoryPath), + ), didCommit: (_) => true, onOperationStaged: (value) => historyOperationId = value, ); diff --git a/lib/src/workspace/workspace_model.dart b/lib/src/workspace/workspace_model.dart index be602526..8932d849 100644 --- a/lib/src/workspace/workspace_model.dart +++ b/lib/src/workspace/workspace_model.dart @@ -47,6 +47,13 @@ extension DocumentKindAiSupport on DocumentKind { this == DocumentKind.writersideMarkdownTopic; } +extension DocumentKindVisualEditingSupport on DocumentKind { + bool get supportsVisualEditing => + this == DocumentKind.markdown || + this == DocumentKind.writersideMarkdownTopic || + this == DocumentKind.writersideXmlTopic; +} + extension DocumentKindSpellingSupport on DocumentKind { bool get supportsSpelling => this == DocumentKind.markdown || diff --git a/lib/src/writerside/writerside_document_parser.dart b/lib/src/writerside/writerside_document_parser.dart index bda103fa..79993152 100644 --- a/lib/src/writerside/writerside_document_parser.dart +++ b/lib/src/writerside/writerside_document_parser.dart @@ -119,12 +119,12 @@ class WritersideDocumentParser { qualifiedName: event.name, attributes: { for (final attribute in event.attributes) - _localName(attribute.name): attribute.value, + _attributeName(attribute.name): attribute.value, }, qualifiedAttributes: [ for (final attribute in event.attributes) WritersideQualifiedAttribute( - name: _localName(attribute.name), + name: _attributeName(attribute.name), qualifiedName: attribute.name, value: attribute.value, ), @@ -420,7 +420,7 @@ Map _attributeSpans({ ); final valueStart = quoteOffset + 1; final valueEnd = valueStart + match.group(3)!.length; - result[_localName(match.group(1)!)] = SourceSpan.fromOffsets( + result[_attributeName(match.group(1)!)] = SourceSpan.fromOffsets( filePath: filePath, source: fullSource, startOffset: openingOffset + valueStart, @@ -432,6 +432,11 @@ Map _attributeSpans({ String _localName(String value) => value.split(':').last.toLowerCase(); +// Attributes in different namespaces can share a local name. Their authored +// identities must remain distinct when a sibling or parent is modified. +String _attributeName(String value) => + value.contains(':') ? value : _localName(value); + String? _safeSubstring(String value, int start, int end) { if (start < 0 || end < start || end > value.length) { return null; diff --git a/lib/src/writerside/writerside_document_resolver.dart b/lib/src/writerside/writerside_document_resolver.dart index c10f6cce..0f30137b 100644 --- a/lib/src/writerside/writerside_document_resolver.dart +++ b/lib/src/writerside/writerside_document_resolver.dart @@ -91,6 +91,62 @@ class ResolvedWritersideDocument { class WritersideDocumentResolver { const WritersideDocumentResolver(); + /// Authored-reference inspection uses exactly the publication link resolver. + Map resolveAuthoredLink({ + required WritersideResolveContext context, + required WritersideDocumentNode node, + required String destination, + String? origin, + }) { + if (destination.contains('%')) { + // Resolve lexical variables and instance conditions through the same + // authored tree used by preview; retain the original occurrence span. + final resolved = resolve(context.topic.document, context).document; + final link = resolved + .walk() + .whereType() + .where( + (candidate) => + candidate.semanticKind == WritersideSemanticKind.link && + candidate.span.filePath == node.span.filePath && + candidate.span.startOffset == node.span.startOffset, + ) + .firstOrNull; + if (link != null) return link.attributes; + } + return _ResolveState(context)._resolveLink( + {'href': destination, if (origin != null) 'origin': origin}, + module: context.module, + topic: context.topic, + node: node, + ); + } + + bool matchesInstance( + String? condition, + WritersideModule module, + WritersideInstance? instance, + ) => _matchesWritersideInstance(condition, module, instance); + + bool authoredIncludeAvailable({ + required WritersideResolveContext context, + required WritersideElementNode include, + }) { + final state = _ResolveState(context); + if (!state._matchesConditions(include.attributes, null, context.module)) { + return false; + } + final result = state._resolveInclude( + include, + module: context.module, + topic: context.topic, + variables: const {}, + includeStack: {}, + inheritedIgnoreVariables: false, + ); + return result.isNotEmpty && !result.any((n) => identical(n, include)); + } + /// Navigation-only attributes share the renderer's condition and variable /// engine, without manufacturing a document for groups and external links. String resolveNavigationTitle( @@ -537,12 +593,7 @@ class _ResolveState { attributes['anchor']?.replaceFirst(RegExp(r'^#'), '') ?? (hash < 0 ? '' : href.substring(hash + 1)); final destination = '$path${anchor.isEmpty ? '' : '#$anchor'}'; - var decodedAnchor = anchor; - try { - decodedAnchor = Uri.decodeComponent(anchor); - } on FormatException { - // Preserve malformed fragments so normal reference validation reports them. - } + final decodedAnchor = decodeWritersideReferenceComponent(anchor); if (Uri.tryParse(path)?.hasScheme == true) { return { 'resolved-destination': destination, diff --git a/lib/src/writerside/writerside_document_serializer.dart b/lib/src/writerside/writerside_document_serializer.dart index 54d94030..fe23d960 100644 --- a/lib/src/writerside/writerside_document_serializer.dart +++ b/lib/src/writerside/writerside_document_serializer.dart @@ -60,6 +60,25 @@ class WritersideDocumentSerializer { } String _serializeElement(WritersideElementNode element) { + final originalAttributes = { + for (final a in element.qualifiedAttributes) a.name: a.value, + }; + final opening = _originalOpening(element.rawSource); + final originalName = RegExp( + r'^<([^\s/>]+)', + ).firstMatch(element.rawSource)?.group(1); + if (opening != null && + !opening.endsWith('/>') && + originalName == element.qualifiedName && + _sameStringMap(element.attributes, originalAttributes)) { + final closing = RegExp( + r']+>\s*$', + dotAll: true, + ).firstMatch(element.rawSource)?.group(0); + if (closing != null) { + return '$opening${element.children.map(_serializeNode).join()}$closing'; + } + } final attributes = _serializeAttributes(element); if (element.children.isEmpty && RegExp(r'/\s*>$').hasMatch(element.rawSource.trim())) { @@ -70,6 +89,22 @@ class WritersideDocumentSerializer { '$children'; } + String? _originalOpening(String source) { + if (!source.startsWith('<')) return null; + String? quote; + for (var i = 1; i < source.length; i++) { + final c = source[i]; + if (quote != null) { + if (c == quote) quote = null; + } else if (c == '"' || c == "'") { + quote = c; + } else if (c == '>') { + return source.substring(0, i + 1); + } + } + return null; + } + String _serializeAttributes(WritersideElementNode element) { final originalValues = { for (final attribute in element.qualifiedAttributes) diff --git a/lib/src/writerside/writerside_html_reference_scanner.dart b/lib/src/writerside/writerside_html_reference_scanner.dart index 539d9260..15dfc6a4 100644 --- a/lib/src/writerside/writerside_html_reference_scanner.dart +++ b/lib/src/writerside/writerside_html_reference_scanner.dart @@ -46,6 +46,89 @@ class WritersideAuthoredHtmlAnchorReference { class WritersideAuthoredHtmlReferenceScanner { const WritersideAuthoredHtmlReferenceScanner(); + List<({String destination, SourceSpan span})> scanMarkdownMedia({ + required String filePath, + required String source, + required List protectedSource, + }) { + final candidates = <({String destination, SourceSpan span})>[]; + final tokenizer = HtmlTokenizer( + source, + generateSpans: true, + attributeSpans: true, + ); + while (tokenizer.moveNext()) { + final token = tokenizer.current; + if (token is! StartTagToken || + !{'img', 'video', 'source', 'audio'}.contains(token.name)) { + continue; + } + for (final attribute in token.attributeSpans ?? []) { + if (!{'src', 'preview-src', 'poster'}.contains(attribute.name)) { + continue; + } + final start = attribute.startValue, end = attribute.endValue; + if (start == null || + end == null || + start >= end || + _rangeIsProtected(protectedSource, start, end)) { + continue; + } + candidates.add(( + destination: attribute.value, + span: SourceSpan.fromOffsets( + filePath: filePath, + source: source, + startOffset: start, + endOffset: end, + ), + )); + } + } + var probed = source; + final probes = {}; + for (var i = candidates.length - 1; i >= 0; i--) { + final probe = 'https://busymark-media-$i.invalid/asset.png'; + probes.add(probe); + probed = probed.replaceRange( + candidates[i].span.startOffset, + candidates[i].span.endOffset, + probe, + ); + } + final rendered = {}; + void inline(BusyInline value) { + if (value.kind == BusyInlineKind.image && value.destination != null) { + rendered.add(value.destination!); + } + value.children.forEach(inline); + } + + void block(BusyBlock value) { + for (final name in ['src', 'preview-src', 'poster']) { + if (value.attributes[name] case final String destination) { + rendered.add(destination); + } + } + value.inlines.forEach(inline); + value.children.forEach(block); + } + + const MarkdownAstAdapter() + .parse( + filePath: filePath, + source: probed, + mode: MarkdownMode.commonMark, + ) + .blocks + .forEach(block); + return [ + for (var i = 0; i < candidates.length; i++) + if (rendered.contains('https://busymark-media-$i.invalid/asset.png')) + candidates[i], + ]; + } + List scanMarkdownAnchors({ required String filePath, required String source, diff --git a/lib/src/writerside/writerside_model.dart b/lib/src/writerside/writerside_model.dart index 081600bd..6106a5f1 100644 --- a/lib/src/writerside/writerside_model.dart +++ b/lib/src/writerside/writerside_model.dart @@ -655,7 +655,8 @@ class WritersideModule { String reference, { WritersideTopic? fromTopic, }) { - final normalized = _normalizedTopicReference(reference); + final decoded = decodeWritersideReferenceComponent(reference); + final normalized = _normalizedTopicReference(decoded); if (normalized.isEmpty) { return fromTopic == null ? const [] : [fromTopic]; } @@ -699,6 +700,22 @@ String _normalizedTopicReference(String value) { return p.normalize(value.trim()).replaceAll(r'\', '/'); } +/// URI destinations may contain authored Unicode or unresolved variables. +String decodeWritersideReferenceComponent(String value) { + try { + return Uri.decodeComponent( + value.replaceAllMapped( + RegExp(r'[^\x00-\x7f]+'), + (match) => Uri.encodeComponent(match[0]!), + ), + ); + } on ArgumentError { + return value; + } on FormatException { + return value; + } +} + extension _FirstOrNull on List { T? get firstOrNull => isEmpty ? null : first; T? get singleOrNull => length == 1 ? single : null; diff --git a/lib/src/writerside/writerside_project.dart b/lib/src/writerside/writerside_project.dart index 4afc4a3f..2ae12c91 100644 --- a/lib/src/writerside/writerside_project.dart +++ b/lib/src/writerside/writerside_project.dart @@ -649,7 +649,10 @@ class WritersideProjectIndex { ); } final hash = value.indexOf('#'); - final normalized = hash < 0 ? value : value.substring(hash + 1); + var normalized = hash < 0 ? value : value.substring(hash + 1); + if (hash >= 0) { + normalized = decodeWritersideReferenceComponent(normalized); + } final module = modulesById[targetModule]; final fromTopic = module?.topics .where((topic) => topic.filePath == filePath) @@ -666,7 +669,25 @@ class WritersideProjectIndex { ? fromTopic : module?.topicByReference(targetPath, fromTopic: fromTopic) ?? module?.topicsById[targetPath]; - if (hash >= 0 && targetPath.isNotEmpty && module != null && topic == null) { + final matchingTopics = + targetPath.isEmpty || + (hash < 0 && + { + WritersideSymbolKind.element, + WritersideSymbolKind.snippet, + WritersideSymbolKind.seealso, + }.contains(kind)) + ? [if (fromTopic != null) fromTopic] + : module?.topicsMatchingReference(targetPath, fromTopic: fromTopic) ?? + const []; + final targetPaths = { + for (final candidate in matchingTopics) candidate.filePath, + if (topic != null) topic.filePath, + }; + if (hash >= 0 && + targetPath.isNotEmpty && + module != null && + targetPaths.isEmpty) { return const []; } final wantsElement = @@ -716,14 +737,14 @@ class WritersideProjectIndex { }.contains(symbol.kind) : kind == null || symbol.kind == kind) && (!wantsElement || - (topic == null + (targetPaths.isEmpty ? filePath == null || symbol.filePath == filePath - : symbol.filePath == topic.filePath)) && + : targetPaths.contains(symbol.filePath))) && (symbol.name == normalized || symbol.qualifiedName == reference || (!wantsElement && symbol.kind == WritersideSymbolKind.topic && - topic?.filePath == symbol.filePath) || + targetPaths.contains(symbol.filePath)) || (resourceMatch && p.basename(symbol.name) == p.basename(normalized))); }); diff --git a/test/fixtures/writerside/reference_conformance/guide.tree b/test/fixtures/writerside/reference_conformance/guide.tree new file mode 100644 index 00000000..f5e516a4 --- /dev/null +++ b/test/fixtures/writerside/reference_conformance/guide.tree @@ -0,0 +1 @@ + diff --git a/test/fixtures/writerside/reference_conformance/topics/source.md b/test/fixtures/writerside/reference_conformance/topics/source.md new file mode 100644 index 00000000..cbfe46ce --- /dev/null +++ b/test/fixtures/writerside/reference_conformance/topics/source.md @@ -0,0 +1,15 @@ +# Reference authoring + +[Inline element](target.topic#actual-id) and [Repeated reference][target]. + +[Second reference][target] + +[target]: target.topic#actual-id "Preserved reference title" + + + +`[Code exclusion](missing.topic)` + +## Explicit heading {id="explicit-heading"} + +[Same document](#explicit-heading) diff --git a/test/fixtures/writerside/reference_conformance/topics/target.topic b/test/fixtures/writerside/reference_conformance/topics/target.topic new file mode 100644 index 00000000..d943e55f --- /dev/null +++ b/test/fixtures/writerside/reference_conformance/topics/target.topic @@ -0,0 +1,5 @@ + +

Element destination.

+

Authored reusable content.

+

XML semantic link

+
diff --git a/test/fixtures/writerside/reference_conformance/writerside.cfg b/test/fixtures/writerside/reference_conformance/writerside.cfg new file mode 100644 index 00000000..a6082ca0 --- /dev/null +++ b/test/fixtures/writerside/reference_conformance/writerside.cfg @@ -0,0 +1 @@ + diff --git a/test/src/app_smoke_test.dart b/test/src/app_smoke_test.dart index fd2d2b99..d8a65aaa 100644 --- a/test/src/app_smoke_test.dart +++ b/test/src/app_smoke_test.dart @@ -10875,6 +10875,9 @@ After break. final editorImage = find.byType(MarkdownImageView); expect(editorAdmonition, findsOneWidget); expect(editorImage, findsOneWidget); + // Compare the shared document surface with the utility pane dismissed. + await tester.tap(find.byTooltip('Element Properties')); + await tester.pumpAndSettle(); expect( find.descendant( of: editorAdmonition, diff --git a/test/src/busymark_design_test.dart b/test/src/busymark_design_test.dart index 576a06b1..000d9e24 100644 --- a/test/src/busymark_design_test.dart +++ b/test/src/busymark_design_test.dart @@ -2083,7 +2083,7 @@ void main() { ), ), ); - expect(popups, hasLength(2)); + expect(popups, hasLength(3)); expect(popups.every((popup) => !popup.transparent), isTrue); expect(popups.every((popup) => popup.elevated), isTrue); expect( @@ -2125,6 +2125,7 @@ void main() { l10n.hardLineBreak, l10n.insertBlankLine, l10n.admonition, + l10n.wsSemanticFormats, l10n.blockquote, l10n.codeBlock, l10n.displayMath, @@ -2169,7 +2170,7 @@ void main() { ), ), ), - findsNWidgets(3), + findsNWidgets(4), ); final actions = tester.widgetList( diff --git a/test/src/busymark_document_test.dart b/test/src/busymark_document_test.dart index f1487bf5..a87958fb 100644 --- a/test/src/busymark_document_test.dart +++ b/test/src/busymark_document_test.dart @@ -3414,7 +3414,7 @@ void main() {} vertical: BusyMarkSpacing.sm, ), ); - expect(toolbarScrollView.clipBehavior, Clip.none); + expect(toolbarScrollView.clipBehavior, Clip.hardEdge); expect(toolbarScrollView.hitTestBehavior, HitTestBehavior.deferToChild); final shownFieldRect = tester.getRect(find.byType(TextField).first); if (topHorizontalToolbar) { @@ -5121,7 +5121,13 @@ void main() {} expect(find.byType(Image), findsOneWidget); expect(find.text('rpi_1.jpg'), findsNothing); - expect(find.byType(TextField), findsNothing); + expect( + find.descendant( + of: find.byKey(const ValueKey('wysiwyg-document-content')), + matching: find.byType(TextField), + ), + findsNothing, + ); await tester.tap(find.byKey(const ValueKey('wysiwyg-image-block-image'))); await tester.pumpAndSettle(); @@ -5131,10 +5137,34 @@ void main() {} expect(find.byType(BusyMarkModalEditorSurface), findsOneWidget); expect(find.byType(BusyMarkModalEditorScaffold), findsOneWidget); expect(find.byType(BusyMarkEditorHeader), findsOneWidget); - expect(find.byType(BusyMarkGroupedList), findsOneWidget); - expect(find.byType(BusyMarkGroupedTextEntry), findsNWidgets(2)); - expect(find.byType(TextFormField), findsNWidgets(2)); - expect(find.byType(YaruListTile), findsNWidgets(2)); + expect( + find.descendant( + of: find.byType(BusyMarkModalEditorScaffold), + matching: find.byType(BusyMarkGroupedList), + ), + findsOneWidget, + ); + expect( + find.descendant( + of: find.byType(BusyMarkModalEditorScaffold), + matching: find.byType(BusyMarkGroupedTextEntry), + ), + findsNWidgets(2), + ); + expect( + find.descendant( + of: find.byType(BusyMarkModalEditorScaffold), + matching: find.byType(TextFormField), + ), + findsNWidgets(2), + ); + expect( + find.descendant( + of: find.byType(BusyMarkModalEditorScaffold), + matching: find.byType(YaruListTile), + ), + findsNWidgets(2), + ); expect(find.byType(BusyMarkDialogShell), findsNothing); expect(find.byType(BusyMarkDialogButton), findsNothing); expect(find.byType(YaruDialogTitleBar), findsNothing); diff --git a/test/src/nextcloud_capabilities_test.dart b/test/src/nextcloud_capabilities_test.dart index bce02a0e..70c6b95e 100644 --- a/test/src/nextcloud_capabilities_test.dart +++ b/test/src/nextcloud_capabilities_test.dart @@ -76,7 +76,11 @@ void main() { .having( (e) => e.code, 'code', - NextcloudCapabilityFailure.unsupported, + notes != null && + (notes['api_version'] is! List || + (notes['api_version'] as List).contains('1.bad')) + ? NextcloudCapabilityFailure.malformed + : NextcloudCapabilityFailure.unsupported, ) .having( (e) => e.message, diff --git a/test/src/nextcloud_notes/notes_api_test.dart b/test/src/nextcloud_notes/notes_api_test.dart index f494a283..d01b7747 100644 --- a/test/src/nextcloud_notes/notes_api_test.dart +++ b/test/src/nextcloud_notes/notes_api_test.dart @@ -254,7 +254,7 @@ void main() { 423: NotesFailureCode.locked, 507: NotesFailureCode.storageFull, 500: NotesFailureCode.server, - 429: NotesFailureCode.server, + 429: NotesFailureCode.throttled, 599: NotesFailureCode.server, }.entries) { test( @@ -452,11 +452,8 @@ void main() { '/etc/passwd', 'https://evil.example/i.png', '../escape', - '%2e%2e/escape', 'a\\b', - 'a/%2e%2e/b', '.attachments.2/foreign.png', - '.attachments%2e2/foreign.png', ]) { await expectLater( client.fetchAttachment( diff --git a/test/src/nextcloud_notes/notes_metadata_race_test.dart b/test/src/nextcloud_notes/notes_metadata_race_test.dart new file mode 100644 index 00000000..42e62f66 --- /dev/null +++ b/test/src/nextcloud_notes/notes_metadata_race_test.dart @@ -0,0 +1,305 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:busymark/src/nextcloud_notes/application/notes_repository.dart'; +import 'package:busymark/src/nextcloud_notes/data/notes_api_client.dart'; +import 'package:busymark/src/nextcloud_notes/data/notes_store.dart'; +import 'package:busymark/src/nextcloud_notes/domain/notes_models.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; + +import 'notes_api_test.dart' show testAccount, serverNote; + +class _RaceFixture { + _RaceFixture(this.draft); + final bool draft; + late Directory directory; + late NotesRepository repository; + DateTime now = DateTime.utc(2026, 10, 10); + final started = Completer>(); + final release = Completer(); + final writes = >[]; + Map? remote; + late final transport = MockClient((request) async { + const root = '/nextcloud/index.php/apps/notes/api/v1/notes'; + expect(request.headers['authorization'], startsWith('Basic ')); + if (request.method == 'GET') { + expect(request.url.path, anyOf(root, '$root/1')); + return http.Response( + jsonEncode( + request.url.path == root ? [if (remote != null) remote] : remote, + ), + 200, + headers: {'content-type': 'application/json; charset=utf-8'}, + ); + } + final creating = request.method == 'POST'; + expect(request.method, creating ? 'POST' : 'PUT'); + expect(request.url.path, creating ? root : '$root/1'); + expect( + request.headers['if-match'], + creating ? isNull : '"${remote!['etag']}"', + ); + final body = jsonDecode(request.body) as Map; + expect( + body.keys, + unorderedEquals(['content', 'title', 'category', 'favorite', 'modified']), + ); + expect(body['content'], 'body'); + expect(body['category'], ''); + expect(body['favorite'], false); + expect(body['modified'], now.millisecondsSinceEpoch ~/ 1000); + writes.add(body); + if (!started.isCompleted) { + started.complete(body); + final status = await release.future; + if (status != 200) { + return http.Response( + '{}', + status, + headers: {if (status == 429) 'Retry-After': '10'}, + ); + } + } + remote = { + ...serverNote(1), + ...body, + 'id': 1, + 'etag': 'written-${writes.length}', + }; + return http.Response( + jsonEncode(remote), + 200, + headers: {'content-type': 'application/json; charset=utf-8'}, + ); + }); + + Future open() async { + repository = NotesRepository( + store: await NotesStore.open(path: '${directory.path}/notes.sqlite3'), + clock: () => now, + clientForAccount: (a) async => NotesApiClient( + client: transport, + account: a, + appPassword: 'fixture', + clock: () => now, + ), + ); + await repository.initialize(); + if (repository.accounts.isEmpty) { + await repository.upsertAccount(testAccount()); + } + } + + Future restart() async { + await repository.dispose(); + await open(); + } + + Future seed() async { + if (draft) { + return (await repository.create( + testAccount().id, + title: 'A', + content: 'body', + )).localId; + } + remote = {...serverNote(1), 'title': 'A', 'category': ''}; + await repository.synchronize(testAccount().id, allowWrites: false); + return repository.notes.single.localId; + } +} + +void main() { + for (final state in [NoteSyncState.pending, NoteSyncState.synced]) { + test( + 'durable metadata evidence independently blocks $state after restart', + () async { + final f = _RaceFixture(false); + f.directory = await Directory.systemTemp.createTemp( + 'notes-metadata-evidence-', + ); + await f.open(); + addTearDown(() async { + await f.repository.dispose(); + f.transport.close(); + await f.directory.delete(recursive: true); + }); + final id = await f.seed(); + final snapshot = f.repository.metadataSnapshot(id); + await f.repository.patchMetadata(snapshot, title: 'B'); + await f.repository.patchMetadata(snapshot, title: 'C'); + final conflicted = f.repository.noteById(id)!; + // Reproduce older transitions without inventing another server observation. + await f.repository.store.saveNote( + conflicted.copyWith( + syncState: state, + ackRevision: conflicted.revision, + retryCount: 1, + failureCode: NotesFailureCode.server, + ), + ); + await f.restart(); + expect(f.repository.noteById(id)!.hasPendingChanges, isTrue); + expect(f.repository.hasRetryableWork(testAccount().id), isFalse); + await f.repository.retryWrites(testAccount().id); + await f.repository.synchronize(testAccount().id); + expect(f.writes, isEmpty); + expect(f.repository.noteById(id)!.title, 'C'); + expect( + f.repository.noteById(id)!.metadataConflict!.alternative.title, + 'B', + ); + expect( + (await f.repository.store.notes()) + .single + .metadataConflict! + .alternative + .title, + 'B', + ); + }, + ); + } + + for (final draft in [false, true]) { + for (final status in [200, 429, 503]) { + for (final choice in [ + NoteConflictResolution.takeRemote, + NoteConflictResolution.keepLocal, + ]) { + test( + 'held ${draft ? 'POST' : 'PUT'} $status cannot publish B/C before $choice, including restart', + () async { + final f = _RaceFixture(draft); + f.directory = await Directory.systemTemp.createTemp( + 'notes-metadata-race-', + ); + await f.open(); + Future? sending; + addTearDown(() async { + if (!f.release.isCompleted) f.release.complete(status); + await sending; + await f.repository.dispose(); + f.transport.close(); + await f.directory.delete(recursive: true); + }); + final id = await f.seed(); + final snapshot = f.repository.metadataSnapshot(id); + await f.repository.patchMetadata(snapshot, title: 'B'); + final sentRevision = f.repository.noteById(id)!.revision; + final previousAck = f.repository.noteById(id)!.ackRevision; + final previousBase = f.repository.noteById(id)!.base; + sending = f.repository.synchronize(testAccount().id); + expect( + (await Future.any([ + f.started.future, + sending.then>( + (_) => throw StateError('No held request'), + ), + ]))['title'], + 'B', + ); + final recordedAttempt = f.repository.noteById(id)!.creationAttempt; + await f.repository.patchMetadata(snapshot, title: 'C'); + final conflicted = f.repository.noteById(id)!; + expect(conflicted.metadataConflict!.alternative.title, 'B'); + f.release.complete(status); + await sending; + final uncertain = draft && status == 503; + final settled = f.repository.noteById(id)!; + expect(settled.revision, conflicted.revision); + expect( + settled.ackRevision, + status == 200 ? sentRevision : previousAck, + ); + expect(settled.title, 'C'); + expect(settled.metadataConflict!.alternative.title, 'B'); + expect( + settled.base?.title, + status == 200 ? 'B' : previousBase?.title, + ); + expect( + settled.base?.etag, + status == 200 ? 'written-1' : previousBase?.etag, + ); + expect( + settled.syncState, + uncertain + ? NoteSyncState.creationUncertain + : NoteSyncState.conflict, + ); + expect(f.repository.hasRetryableWork(testAccount().id), isFalse); + expect(f.writes.map((w) => w['title']), ['B']); + if (uncertain) { + expect( + settled.creationAttempt!.wireBody, + recordedAttempt!.wireBody, + ); + } + // Retry deadlines expire, but unresolved metadata still blocks every pass. + f.now = f.now.add(const Duration(minutes: 1)); + await f.repository.synchronize(testAccount().id); + await f.restart(); + await f.repository.synchronize(testAccount().id); + expect(f.writes.map((w) => w['title']), ['B']); + expect( + f.repository.noteById(id)!.ackRevision, + status == 200 ? sentRevision : previousAck, + ); + expect( + f.repository.noteById(id)!.syncState, + uncertain + ? NoteSyncState.creationUncertain + : NoteSyncState.conflict, + ); + expect(f.repository.noteById(id)!.title, 'C'); + expect( + f.repository.noteById(id)!.metadataConflict!.alternative.title, + 'B', + ); + await f.repository.resolveConflict(id, choice); + final chosen = choice == NoteConflictResolution.takeRemote + ? 'B' + : 'C'; + expect(f.repository.noteById(id)!.title, chosen); + await f.repository.synchronize(testAccount().id); + if (uncertain) { + // A metadata decision cannot establish the outcome of a possibly + // executed POST. Explicit creation recovery is a separate decision. + expect(f.writes.length, 1); + expect( + f.repository.noteById(id)!.syncState, + NoteSyncState.creationUncertain, + ); + expect( + f.repository.noteById(id)!.creationAttempt!.wireBody, + recordedAttempt!.wireBody, + ); + final recovered = await f.repository.recoverAsNew( + id, + creationDecision: true, + ); + await f.repository.synchronize(testAccount().id); + expect(f.repository.noteById(recovered.localId)!.title, chosen); + expect( + f.repository.noteById(id)!.syncState, + NoteSyncState.creationUncertain, + ); + } else { + expect( + f.repository.noteById(id)!.syncState, + NoteSyncState.synced, + ); + expect(f.repository.noteById(id)!.metadataConflict, isNull); + } + expect(f.remote!['title'], chosen); + expect(f.writes.last['title'], chosen); + }, + ); + } + } + } +} diff --git a/test/src/nextcloud_notes/notes_milestone_repository_test.dart b/test/src/nextcloud_notes/notes_milestone_repository_test.dart new file mode 100644 index 00000000..76c3b66d --- /dev/null +++ b/test/src/nextcloud_notes/notes_milestone_repository_test.dart @@ -0,0 +1,977 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:busymark/src/nextcloud_notes/application/notes_media.dart'; +import 'package:busymark/src/nextcloud_notes/application/notes_repository.dart'; +import 'package:busymark/src/nextcloud_notes/data/notes_api_client.dart'; +import 'package:busymark/src/nextcloud_notes/data/notes_capabilities.dart'; +import 'package:busymark/src/nextcloud_notes/data/notes_store.dart'; +import 'package:busymark/src/nextcloud_notes/domain/notes_models.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:sqlite3/sqlite3.dart'; + +import 'notes_api_test.dart' show testAccount, serverNote; + +void main() { + late Directory directory; + late DateTime now; + final repositories = []; + setUp(() async { + directory = await Directory.systemTemp.createTemp('notes-m1-repository-'); + now = DateTime.utc(2026, 9, 1, 12, 0, 0, 123); + }); + tearDown(() async { + for (final r in repositories) { + await r.dispose(); + } + repositories.clear(); + await directory.delete(recursive: true); + }); + Future open( + http.Client client, { + Future Function(NextcloudAccount)? capabilities, + }) async { + final r = NotesRepository( + store: await NotesStore.open(path: '${directory.path}/notes.sqlite3'), + clock: () => now, + fetchCapabilities: capabilities, + clientForAccount: (account) async => NotesApiClient( + client: client, + account: account, + appPassword: 'fixture', + clock: () => now, + ), + ); + await r.initialize(); + if (r.accounts.isEmpty) await r.upsertAccount(testAccount()); + repositories.add(r); + return r; + } + + Future restart(NotesRepository r) async { + await r.dispose(); + repositories.remove(r); + } + + Future seed(NotesRepository r) async { + await r.synchronize(testAccount().id, allowWrites: false); + return r.notes.single.localId; + } + + test( + 'a rejected creation route 404 never establishes draft deletion', + () async { + var creates = 0; + final r = await open( + MockClient((request) async { + expect( + request.url.path, + '/nextcloud/index.php/apps/notes/api/v1/notes', + ); + if (request.method == 'GET') return http.Response('[]', 200); + expect(request.method, 'POST'); + creates++; + return http.Response('{}', 404); + }), + ); + final draft = await r.create( + testAccount().id, + content: 'retained local creation', + ); + await r.synchronize(testAccount().id); + expect(r.noteById(draft.localId)!.syncState, NoteSyncState.rejected); + expect( + r.noteById(draft.localId)!.failureScope, + NotesRequestScope.collection, + ); + expect(r.noteById(draft.localId)!.creationAttempt, isNull); + expect(r.noteById(draft.localId)!.creationNeverSent, isTrue); + await r.synchronize(testAccount().id); + expect(creates, 1); + expect(r.noteById(draft.localId)!.content, 'retained local creation'); + }, + ); + + test( + 'attachment-only 404 cannot delete its owner or discard upload bytes', + () async { + final transport = MockClient((request) async { + if (request.url.path.endsWith('/v1/notes')) { + expect(request.method, 'GET'); + return http.Response(jsonEncode([serverNote(1)]), 200); + } + expect( + request.url.path, + '/nextcloud/index.php/apps/notes/api/v1.4/attachment/1', + ); + expect(request.method, anyOf('GET', 'POST')); + return http.Response('{}', 404); + }); + final r = await open(transport); + final id = await seed(r); + await expectLater( + r.resolveMedia(testAccount().id, id, '.attachments.1/missing.png'), + throwsA( + isA().having( + (e) => e.scope, + 'scope', + NotesRequestScope.attachment, + ), + ), + ); + expect(r.noteById(id)!.syncState, NoteSyncState.synced); + final attachment = await r.addAttachment( + id, + filename: 'draft.png', + bytes: Uint8List.fromList([1, 2, 3]), + ); + await r.save(id, content: '![image](${attachment.reference})'); + await r.synchronize(testAccount().id); + expect(r.noteById(id)!.syncState, NoteSyncState.rejected); + expect(r.noteById(id)!.failureScope, NotesRequestScope.attachment); + expect( + await r.store.attachmentBytes(attachment.id), + Uint8List.fromList([1, 2, 3]), + ); + expect(r.noteById(id)!.content, contains(attachment.reference)); + }, + ); + + test( + 'authoritative upload path survives a later local transaction failure and restart', + () async { + var uploads = 0; + var puts = 0; + final transport = MockClient((request) async { + if (request.url.path.endsWith('/v1/notes')) { + expect(request.method, 'GET'); + return http.Response(jsonEncode([serverNote(1)]), 200); + } + if (request.method == 'POST') { + expect( + request.url.path, + '/nextcloud/index.php/apps/notes/api/v1.4/attachment/1', + ); + expect( + request.headers['content-type'], + startsWith('multipart/form-data;'), + ); + uploads++; + final db = sqlite3.open('${directory.path}/notes.sqlite3'); + db.execute( + "CREATE TRIGGER reject_publication BEFORE UPDATE ON notes BEGIN SELECT RAISE(ABORT, 'fixture'); END", + ); + db.close(); + return http.Response( + jsonEncode({'filename': '.attachments.1/literal%2F.png'}), + 200, + ); + } + expect(request.method, 'PUT'); + expect( + request.url.path, + '/nextcloud/index.php/apps/notes/api/v1/notes/1', + ); + expect(request.headers['if-match'], '"abc"'); + puts++; + final body = jsonDecode(request.body) as Map; + expect(body['content'], contains('.attachments.1/literal%252F.png')); + return http.Response( + jsonEncode({...serverNote(1), ...body, 'etag': 'published'}), + 200, + ); + }); + var r = await open(transport); + final id = await seed(r); + final attachment = await r.addAttachment( + id, + filename: 'literal%2F.png', + bytes: Uint8List.fromList([7, 8]), + ); + await r.save(id, content: '![image](${attachment.reference})'); + await expectLater(r.synchronize(testAccount().id), throwsStateError); + final uploaded = (await r.store.attachments(id)).single; + expect(uploaded.state, 'uploaded'); + expect(uploaded.remotePath, '.attachments.1/literal%2F.png'); + final db = sqlite3.open('${directory.path}/notes.sqlite3'); + db.execute('DROP TRIGGER reject_publication'); + db.close(); + await restart(r); + r = await open(transport); + await r.synchronize(testAccount().id); + expect(uploads, 1); + expect(puts, 1); + expect(r.noteById(id)!.syncState, NoteSyncState.synced); + }, + ); + + test( + 'permanent rejection blocks polling and restart, a new edit permits correction', + () async { + var puts = 0; + var reject = true; + final transport = MockClient((request) async { + expect( + request.url.path, + startsWith('/nextcloud/index.php/apps/notes/api/v1/notes'), + ); + if (request.method == 'GET') { + return http.Response(jsonEncode([serverNote(1)]), 200); + } + expect(request.method, 'PUT'); + puts++; + if (reject) return http.Response('{}', 400); + return http.Response( + jsonEncode({...serverNote(1), ...jsonDecode(request.body) as Map}), + 200, + ); + }); + var r = await open(transport); + final id = await seed(r); + await r.save(id, content: 'edited'); + await r.synchronize(testAccount().id); + expect(r.noteById(id)!.syncState, NoteSyncState.rejected); + for (var i = 0; i < 3; i++) { + await r.synchronize(testAccount().id); + } + await restart(r); + r = await open(transport); + await r.synchronize(testAccount().id); + expect(puts, 1); + reject = false; + await r.save(id, content: 'corrected'); + await r.synchronize(testAccount().id); + expect(puts, 2); + expect(r.noteById(id)!.content, 'corrected'); + }, + ); + + for (final status in [429, 503]) { + test( + 'media Retry-After $status retains bytes and not-before across restart', + () async { + var downloads = 0; + final transport = MockClient((request) async { + expect(request.method, 'GET'); + if (request.url.path.endsWith('/v1/notes')) { + return http.Response( + jsonEncode([ + serverNote(1, content: '![image](.attachments.1/a.png)'), + ]), + 200, + ); + } + expect( + request.url.path, + '/nextcloud/index.php/apps/notes/api/v1.4/attachment/1', + ); + expect(request.url.queryParameters['path'], '.attachments.1/a.png'); + downloads++; + if (downloads == 2) { + return http.Response( + '{}', + status, + headers: {'Retry-After': '3600'}, + ); + } + return http.Response.bytes([downloads], 200); + }); + var r = await open(transport); + final id = await seed(r); + final path = await r.resolveMedia( + testAccount().id, + id, + '.attachments.1/a.png', + ); + final validated = (await r.store.attachments(id)).single.validatedAt; + now = now.add(const Duration(minutes: 6)); + expect( + await r.resolveMedia(testAccount().id, id, '.attachments.1/a.png'), + path, + ); + expect((await r.store.attachments(id)).single.validatedAt, validated); + final deadline = now.add(const Duration(hours: 1)); + expect(r.accountById(testAccount().id)!.throttleNotBefore, deadline); + await restart(r); + r = await open(transport); + expect( + await r.resolveMedia(testAccount().id, id, '.attachments.1/a.png'), + path, + ); + expect(downloads, 2); + now = deadline; + final fresh = await r.resolveMedia( + testAccount().id, + id, + '.attachments.1/a.png', + ); + expect(fresh, isNot(path)); + expect(downloads, 3); + expect(await File(fresh!).readAsBytes(), [3]); + }, + ); + } + test( + '429 not-before survives manual retry and restart; HTTP date and long delay', + () async { + var puts = 0; + final deadline = DateTime.fromMillisecondsSinceEpoch( + now.add(const Duration(days: 2)).millisecondsSinceEpoch ~/ 1000 * 1000, + isUtc: true, + ); + final transport = MockClient((request) async { + if (request.method == 'GET') { + return http.Response(jsonEncode([serverNote(1)]), 200); + } + expect(request.method, 'PUT'); + puts++; + return http.Response( + '{}', + 429, + headers: {'rEtRy-AfTeR': HttpDate.format(deadline)}, + ); + }); + var r = await open(transport); + final id = await seed(r); + await r.save(id, content: 'edited'); + await r.synchronize(testAccount().id); + expect(r.noteById(id)!.retryNotBefore, deadline); + await r.retryWrites(testAccount().id); + await r.synchronize(testAccount().id); + expect(puts, 1); + await restart(r); + r = await open(transport); + now = now.add(const Duration(days: 1)); + await r.synchronize(testAccount().id); + expect(puts, 1); + now = deadline; + await r.synchronize(testAccount().id); + expect(puts, 2); + expect( + retryAfter( + '999999999999999999999999999', + now, + )!.isAfter(now.add(const Duration(days: 365))), + isTrue, + ); + expect(retryAfter('nonsense', now), isNull); + expect(retryAfter('-1', now), isNull); + expect( + retryAfter(HttpDate.format(now.subtract(const Duration(days: 1))), now), + now, + ); + }, + ); + + test( + 'legacy poisoned drafts and server edits recover, true absence remains protected', + () async { + final r = await open( + MockClient((request) async { + if (request.method == 'GET') { + expect( + request.url.queryParameters.containsKey('pruneBefore'), + isFalse, + ); + return http.Response( + jsonEncode([serverNote(1), serverNote(2, etag: 'changed')]), + 200, + ); + } + fail('Read-only recovery must not publish'); + }), + ); + final base = NoteState.fromJson(serverNote(1)); + for (final id in [1, 2, 3]) { + await r.store.saveNote( + NextcloudNote( + localId: '00000000-0000-4000-8000-00000000000$id', + accountId: testAccount().id, + serverId: id, + content: 'retained $id', + title: 'Title', + etag: base.etag, + base: base, + revision: 4, + ackRevision: 1, + syncState: NoteSyncState.deletedRemotely, + ), + ); + } + final draft = await r.create(testAccount().id, content: 'retained draft'); + await r.store.saveNote( + draft.copyWith(syncState: NoteSyncState.deletedRemotely), + ); + final ambiguous = NextcloudNote.fromJson({ + ...draft.toJson(), + 'localId': '00000000-0000-4000-8000-000000000009', + 'creationNeverSent': false, + 'localActivityMicros': null, + 'syncState': 'deletedRemotely', + }); + await r.store.saveNote(ambiguous); + await restart(r); + final reopened = await open( + MockClient((request) async { + expect(request.method, 'GET'); + expect(request.url.queryParameters['pruneBefore'], isNull); + return http.Response( + jsonEncode([serverNote(1), serverNote(2, etag: 'changed')]), + 200, + ); + }), + ); + await reopened.synchronize(testAccount().id, allowWrites: false); + expect( + reopened.noteById(ambiguous.localId)!.syncState, + NoteSyncState.recoveryRequired, + ); + await reopened.save(ambiguous.localId, content: 'Further retained work'); + await reopened.synchronize(testAccount().id, allowWrites: false); + expect( + reopened.noteById(ambiguous.localId)!.syncState, + NoteSyncState.recoveryRequired, + ); + await reopened.resolveConflict( + ambiguous.localId, + NoteConflictResolution.saveAsNew, + ); + expect( + reopened.notes.any( + (n) => + n.localId != ambiguous.localId && + n.content == 'Further retained work' && + n.creationNeverSent, + ), + isTrue, + ); + expect( + reopened.noteById(ambiguous.localId)!.ackRevision, + ambiguous.ackRevision, + ); + expect( + reopened.noteById(draft.localId)!.syncState, + NoteSyncState.pending, + ); + final notes = {for (final n in reopened.notes) n.serverId: n}; + expect(notes[1]!.syncState, NoteSyncState.pending); + expect(notes[1]!.ackRevision, 1); + expect(notes[2]!.syncState, NoteSyncState.conflict); + expect(notes[2]!.content, 'retained 2'); + expect(notes[3]!.syncState, NoteSyncState.deletedRemotely); + expect(notes[3]!.deletionEvidence, 'completeList'); + }, + ); + + test( + 'edit time survives delay, no-op, restart and a newer edit during acknowledgment', + () async { + final started = Completer(); + final reply = Completer(); + var delayPut = false; + final bodies = []; + var remote = serverNote(1); + final transport = MockClient((request) async { + if (request.method == 'GET') { + return http.Response(jsonEncode([remote]), 200); + } + final body = jsonDecode(request.body) as Map; + bodies.add(body); + if (delayPut) { + started.complete(); + await reply.future; + } + remote = {...remote, ...body, 'etag': 'written-${bodies.length}'}; + return http.Response(jsonEncode(remote), 200); + }); + var r = await open(transport); + final id = await seed(r); + final firstTime = now; + await r.save(id, content: 'first'); + now = now.add(const Duration(days: 1)); + await r.save(id, content: 'first'); + expect( + r.noteById(id)!.localActivityMicros, + firstTime.microsecondsSinceEpoch, + ); + await restart(r); + r = await open(transport); + delayPut = true; + final sync = r.synchronize(testAccount().id); + await started.future; + final secondTime = now; + await r.save(id, content: 'second'); + reply.complete(); + await sync; + expect( + bodies.single['modified'], + firstTime.millisecondsSinceEpoch ~/ 1000, + ); + expect( + r.noteById(id)!.localActivityMicros, + secondTime.microsecondsSinceEpoch, + ); + expect(r.noteById(id)!.hasPendingChanges, isTrue); + delayPut = false; + now = now.add(const Duration(days: 1)); + await r.synchronize(testAccount().id); + expect( + bodies.last['modified'], + secondTime.millisecondsSinceEpoch ~/ 1000, + ); + }, + ); + + test( + 'metadata title patch preserves a concurrent remote category and dirty content', + () async { + var remote = serverNote(1); + final r = await open( + MockClient((request) async { + expect(request.method, 'GET'); + return http.Response(jsonEncode([remote]), 200); + }), + ); + final id = await seed(r); + final snapshot = r.metadataSnapshot(id); + remote = { + ...remote, + 'category': 'Remote category', + 'etag': 'category-change', + }; + await r.synchronize(testAccount().id, allowWrites: false); + await r.save(id, content: 'dirty editor content'); + final patched = await r.patchMetadata(snapshot, title: 'Intended title'); + expect(patched.category, 'Remote category'); + expect(patched.title, 'Intended title'); + expect(patched.content, 'dirty editor content'); + expect(patched.syncState, NoteSyncState.pending); + }, + ); + + test( + 'metadata divergent field retains both values, convergence and no-op make no revision', + () async { + final r = await open( + MockClient( + (_) async => http.Response(jsonEncode([serverNote(1)]), 200), + ), + ); + final id = await seed(r); + final snapshot = r.metadataSnapshot(id); + await r.patchMetadata(snapshot, title: 'Other local action'); + final changed = await r.patchMetadata(snapshot, title: 'Dialog intent'); + expect(changed.syncState, NoteSyncState.conflict); + expect(changed.title, 'Dialog intent'); + expect(changed.remote, isNull); + expect(changed.metadataConflict!.alternative.title, 'Other local action'); + final convergence = r.metadataSnapshot(id); + final revision = changed.revision; + final time = changed.localActivityMicros; + await r.patchMetadata(convergence, title: 'Dialog intent'); + expect(r.noteById(id)!.revision, revision); + expect(r.noteById(id)!.localActivityMicros, time); + }, + ); + + test( + 'metadata patch revalidates read-only permission and account removal', + () async { + var readonly = false; + final r = await open( + MockClient( + (_) async => http.Response( + jsonEncode([serverNote(1, readonly: readonly)]), + 200, + ), + ), + ); + final id = await seed(r); + final snapshot = r.metadataSnapshot(id); + readonly = true; + await r.synchronize(testAccount().id, allowWrites: false); + await expectLater( + r.patchMetadata(snapshot, title: 'blocked'), + throwsA( + isA().having( + (e) => e.code, + 'permission', + NotesFailureCode.forbidden, + ), + ), + ); + await r.removeAccount(testAccount().id); + await expectLater( + r.patchMetadata(snapshot, favorite: true), + throwsA(isA()), + ); + }, + ); + + test( + 'same-path refresh changes an open media context even with unchanged note ETag', + () async { + var bytes = [1, 2]; + var downloads = 0; + var failed = false; + final r = await open( + MockClient((request) async { + if (request.url.path.contains('/attachment/')) { + expect(request.method, 'GET'); + expect(request.url.queryParameters['path'], '.attachments.1/a.png'); + downloads++; + return failed + ? http.Response('', 503) + : http.Response.bytes(bytes, 200); + } + expect(request.url.path.endsWith('/v1/notes'), isTrue); + return http.Response( + jsonEncode([serverNote(1, content: '![a](.attachments.1/a.png)')]), + 200, + ); + }), + ); + final id = await seed(r); + r.setDisplayedMediaNotes([id]); + final media = NextcloudDocumentMedia(r, testAccount().id, id); + final oldContext = media.context; + final first = await media.resolve('.attachments.1/a.png'); + expect(await File(first!).readAsBytes(), [1, 2]); + bytes = [3, 4]; + await r.refreshDisplayedMedia(force: true); + final second = await media.resolve('.attachments.1/a.png'); + expect(await File(second!).readAsBytes(), [3, 4]); + expect(second, isNot(first)); + expect(media.context.identity, isNot(oldContext.identity)); + final validated = (await r.attachments(id)).single.validatedAt; + failed = true; + now = now.add(const Duration(minutes: 6)); + await r.refreshDisplayedMedia(); + expect( + await File( + (await media.resolve('.attachments.1/a.png'))!, + ).readAsBytes(), + [3, 4], + ); + expect((await r.attachments(id)).single.validatedAt, validated); + final files = await Directory( + '${directory.path}/media/${testAccount().id}/$id', + ).list().toList(); + expect(files.length, 1); + expect(downloads, greaterThanOrEqualTo(3)); + }, + ); + + for (final action in ['deletion', 'removal', 'disposal']) { + test( + 'concurrent media coalesces and $action fences a late download', + () async { + final started = Completer(); + final reply = Completer(); + var downloads = 0; + final r = await open( + MockClient((request) async { + if (request.url.path.contains('/attachment/')) { + downloads++; + started.complete(); + await reply.future; + return http.Response.bytes([9], 200); + } + return http.Response( + jsonEncode([ + serverNote(1, content: '![a](.attachments.1/a.png)'), + ]), + 200, + ); + }), + ); + final id = await seed(r); + if (action == 'deletion') { + await r.store.saveAttachment( + NotesAttachment( + id: '22175816-3b00-46a8-8d16-e11e6a583cc4', + noteId: id, + filename: 'a.png', + reference: '.attachments.1/a.png', + remotePath: '.attachments.1/a.png', + state: 'cached', + ), + Uint8List.fromList([1]), + ); + } + final a = r.resolveMedia(testAccount().id, id, '.attachments.1/a.png'); + final b = r.resolveMedia(testAccount().id, id, '.attachments.1/a.png'); + await started.future; + expect(downloads, 1); + Future? disposing; + if (action == 'deletion') { + final attachment = (await r.attachments(id)).single; + await r.store.updateAttachment( + NotesAttachment( + id: attachment.id, + noteId: id, + filename: 'a.png', + reference: '.attachments.1/a.png', + remotePath: '.attachments.1/a.png', + state: 'deleted', + ), + ); + } + if (action == 'removal') await r.removeAccount(testAccount().id); + if (action == 'disposal') { + disposing = r.dispose(); + repositories.remove(r); + } + reply.complete(); + await Future.wait([ + a.then((p) { + expect(p, isNull); + }), + b.then((p) { + expect(p, isNull); + }), + ]); + await disposing; + expect( + await Directory('${directory.path}/downloads').list().toList(), + isEmpty, + ); + }, + ); + } + + test( + 'settings partial update, normalization, pending path blocker and checkpoint reset', + () async { + var settings = {'notesPath': 'Notes', 'fileSuffix': '.txt'}; + final puts = []; + final r = await open( + MockClient((request) async { + if (request.url.path.endsWith('/v1/settings')) { + if (request.method == 'PUT') { + final body = jsonDecode(request.body) as Map; + puts.add(body); + settings = { + ...settings, + for (final e in body.entries) + e.key as String: (e.value as String).trim(), + }; + } + return http.Response(jsonEncode({...settings, 'unknown': 1}), 200); + } + expect(request.method, 'GET'); + return http.Response( + jsonEncode([serverNote(1)]), + 200, + headers: { + 'ETag': '"checkpoint"', + 'Last-Modified': 'Tue, 01 Sep 2026 12:00:00 GMT', + }, + ); + }), + ); + final id = await seed(r); + final original = await r.getSettings(testAccount().id); + final result = await r.changeSettings(testAccount().id, original, { + 'fileSuffix': ' .custom ', + }); + expect(result.fileSuffix, '.custom'); + expect(puts.single, {'fileSuffix': ' .custom '}); + expect(r.accounts.single.listEtag, isNull); + await r.save(id, content: 'pending'); + await expectLater( + r.changeSettings(testAccount().id, result, {'notesPath': 'Different'}), + throwsA(isA()), + ); + expect(puts.length, 1); + expect(r.noteById(id)!.content, 'pending'); + }, + ); + + test( + 'lost settings PUT response reconciles by GET, restart retains an unreadable attempt', + () async { + var settings = {'notesPath': 'Notes', 'fileSuffix': '.txt'}; + var failedRead = false; + var puts = 0; + final transport = MockClient((request) async { + expect(request.url.path.endsWith('/v1/settings'), isTrue); + if (request.method == 'PUT') { + puts++; + settings = { + ...settings, + ...Map.from(jsonDecode(request.body) as Map), + }; + failedRead = true; + throw http.ClientException('lost'); + } + if (failedRead) throw http.ClientException('offline'); + return http.Response(jsonEncode(settings), 200); + }); + var r = await open(transport); + final original = await r.getSettings(testAccount().id); + await expectLater( + r.changeSettings(testAccount().id, original, {'notesPath': 'New'}), + throwsA(isA()), + ); + expect(r.accounts.single.settingsAttempt, isNotNull); + await restart(r); + r = await open(transport); + await expectLater( + r.create(testAccount().id), + throwsA(isA()), + ); + failedRead = false; + final result = await r.reconcileSettings(testAccount().id); + expect(result.notesPath, 'New'); + expect(r.accounts.single.settingsAttempt, isNull); + expect(puts, 1); + expect(r.accounts.single.listEtag, isNull); + }, + ); + + test( + 'capability freshness updates deletion gate and preserves current checkpoint', + () async { + var version = '6.0.2'; + var calls = 0; + final r = await open( + MockClient((request) async { + expect(request.method, 'GET'); + return http.Response('[]', 200, headers: {'ETag': '"checkpoint"'}); + }), + capabilities: (_) async { + calls++; + return NotesCapabilities(appVersion: version, apiVersion: '1.4'); + }, + ); + await r.synchronize(testAccount().id); + expect(r.accounts.single.supportsAttachmentDeletion, isFalse); + expect(calls, 1); + version = '6.1.0'; + await r.maintainCapabilities(testAccount().id, force: true); + expect(r.accounts.single.supportsAttachmentDeletion, isTrue); + expect(r.accounts.single.listEtag, '"checkpoint"'); + await r.recordApiVersions( + testAccount().id, + r.accountGeneration(testAccount().id), + r.capabilityEpoch(testAccount().id), + '0.2, 1.3', + ); + expect(r.accounts.single.apiSupported, isFalse); + expect(r.accounts.single.supportsAttachmentDeletion, isFalse); + await r.recordApiVersions( + testAccount().id, + r.accountGeneration(testAccount().id), + r.capabilityEpoch(testAccount().id), + 'garbage', + ); + expect(r.accounts.single.apiSupported, isFalse); + expect( + await r.create(testAccount().id, content: 'local recovery'), + isNotNull, + ); + }, + ); + + test( + 'capability and settings responses cannot restore removed account state', + () async { + final started = Completer(); + final reply = Completer(); + final r = await open( + MockClient((_) async => http.Response('{}', 404)), + capabilities: (_) { + started.complete(); + return reply.future; + }, + ); + final pending = r.maintainCapabilities(testAccount().id); + await started.future; + await r.removeAccount(testAccount().id); + reply.complete( + const NotesCapabilities(appVersion: '6.2.0', apiVersion: '1.5'), + ); + await pending; + expect(r.accounts, isEmpty); + expect(await r.store.accounts(), isEmpty); + }, + ); + + for (final schema in [2, 3, 4, 5]) { + test( + 'v$schema migration retains exact outbox and bytes; failed recovery rolls back fence; v7 rejected', + () async { + final r = await open(MockClient((_) async => http.Response('[]', 200))); + final draft = await r.create(testAccount().id, content: 'retained'); + final attachment = await r.addAttachment( + draft.localId, + filename: 'a.bin', + bytes: Uint8List.fromList([1, 2]), + ); + await restart(r); + var db = sqlite3.open('${directory.path}/notes.sqlite3'); + db.execute('DROP TRIGGER IF EXISTS notes_search_delete'); + for (final table in [ + 'note_search', + 'note_search_map', + 'offline_requirements', + 'import_items', + 'import_operations', + ]) { + db.execute('DROP TABLE IF EXISTS $table'); + } + db.execute('PRAGMA user_version=$schema'); + final outbox = db.select('SELECT data FROM outbox').single['data']; + db.close(); + final migrated = await open( + MockClient((_) async => http.Response('[]', 200)), + ); + expect( + migrated.notes.single.localActivityMicros, + draft.localActivityMicros, + ); + expect(await migrated.store.attachmentBytes(attachment.id), [1, 2]); + await restart(migrated); + db = sqlite3.open('${directory.path}/notes.sqlite3'); + expect(db.select('PRAGMA user_version').single.values.single, 6); + expect(db.select('SELECT data FROM outbox').single['data'], outbox); + db.execute('DROP TRIGGER IF EXISTS notes_search_delete'); + for (final table in [ + 'note_search', + 'note_search_map', + 'offline_requirements', + 'import_items', + 'import_operations', + ]) { + db.execute('DROP TABLE IF EXISTS $table'); + } + db.execute('PRAGMA user_version=$schema'); + db.execute("UPDATE notes SET data='{}'"); + db.close(); + await expectLater( + NotesStore.open(path: '${directory.path}/notes.sqlite3'), + throwsStateError, + ); + db = sqlite3.open('${directory.path}/notes.sqlite3'); + expect(db.select('PRAGMA user_version').single.values.single, schema); + expect( + db.select( + "SELECT name FROM sqlite_master WHERE name IN ('note_search','offline_requirements','import_items','import_operations')", + ), + isEmpty, + ); + db.execute('PRAGMA user_version=7'); + db.close(); + await expectLater( + NotesStore.open(path: '${directory.path}/notes.sqlite3'), + throwsStateError, + ); + }, + ); + } +} diff --git a/test/src/nextcloud_notes/notes_milestone_test.dart b/test/src/nextcloud_notes/notes_milestone_test.dart new file mode 100644 index 00000000..861bcae2 --- /dev/null +++ b/test/src/nextcloud_notes/notes_milestone_test.dart @@ -0,0 +1,185 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:busymark/src/nextcloud_notes/application/notes_repository.dart'; +import 'package:busymark/src/nextcloud_notes/data/notes_api_client.dart'; +import 'package:busymark/src/nextcloud_notes/data/notes_store.dart'; +import 'package:busymark/src/nextcloud_notes/domain/notes_models.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; + +import 'notes_api_test.dart' show testAccount, serverNote; + +void main() { + test( + 'API version headers are observed on scoped errors and attachment streams', + () async { + final observed = []; + final directory = await Directory.systemTemp.createTemp('notes-headers-'); + addTearDown(() => directory.delete(recursive: true)); + final client = NotesApiClient( + account: testAccount(), + appPassword: 'fixture', + onApiVersions: (value) async => observed.add(value), + client: MockClient((request) async { + expect(request.headers['authorization'], startsWith('Basic ')); + if (request.url.path.endsWith('/v1/settings')) { + expect(request.method, 'GET'); + return http.Response( + '{}', + 404, + headers: {'X-NoTeS-Api-Versions': '1.4'}, + ); + } + expect( + request.url.path, + '/nextcloud/index.php/apps/notes/api/v1.4/attachment/1', + ); + expect(request.url.queryParameters['path'], '.attachments.1/a.png'); + return http.Response.bytes( + [9], + 200, + headers: {'X-NOTES-API-VERSIONS': '1.5'}, + ); + }), + ); + await expectLater( + client.getSettings(), + throwsA( + isA().having( + (e) => e.scope, + 'scope', + NotesRequestScope.settings, + ), + ), + ); + await client.fetchAttachment( + 1, + '.attachments.1/a.png', + destination: File('${directory.path}/cached'), + ); + expect(observed, ['1.4', '1.5']); + }, + ); + test( + 'collection 404 preserves clean, edited and local-only work across restart', + () async { + final directory = await Directory.systemTemp.createTemp('notes-m1-'); + addTearDown(() => directory.delete(recursive: true)); + var failing = false; + var writes = 0; + final transport = MockClient((request) async { + expect( + request.url.path, + startsWith('/nextcloud/index.php/apps/notes/api/v1/notes'), + ); + if (failing) return http.Response('{}', 404); + if (request.method == 'GET') { + return http.Response(jsonEncode([serverNote(1)]), 200); + } + writes++; + final body = jsonDecode(request.body) as Map; + return http.Response( + jsonEncode({ + ...serverNote(request.method == 'POST' ? 2 : 1), + ...body, + 'etag': 'written', + }), + 200, + ); + }); + Future open() async { + final repository = NotesRepository( + store: await NotesStore.open(path: '${directory.path}/notes.sqlite3'), + clientForAccount: (a) async => NotesApiClient( + client: transport, + account: a, + appPassword: 'test', + ), + ); + await repository.initialize(); + return repository; + } + + var repository = await open(); + await repository.upsertAccount(testAccount()); + await repository.synchronize(testAccount().id); + final remoteId = repository.notes.single.localId; + failing = true; + await repository.synchronize(testAccount().id); + expect(repository.noteById(remoteId)!.syncState, NoteSyncState.synced); + await repository.save(remoteId, content: 'offline edit'); + final draft = await repository.create( + testAccount().id, + content: 'offline draft', + ); + await repository.synchronize(testAccount().id); + expect( + repository.noteById(remoteId)!.syncState, + isNot(NoteSyncState.deletedRemotely), + ); + expect( + repository.noteById(draft.localId)!.syncState, + isNot(NoteSyncState.deletedRemotely), + ); + await repository.dispose(); + repository = await open(); + failing = false; + await repository.synchronize(testAccount().id); + expect(writes, 2); + expect(repository.noteById(remoteId)!.content, 'offline edit'); + expect(repository.noteById(draft.localId)!.serverId, 2); + await repository.dispose(); + }, + ); + + for (final name in [ + 'é 日本.png', + 'literal%.png', + 'space # (1).png', + '%2F.png', + '%2e%2e.png', + ]) { + test('raw attachment round trip: $name', () async { + final path = '.attachments.1/$name'; + expect(isSafeAttachmentPath(path), isTrue); + expect(isNoteAttachmentPath(1, path), isTrue); + expect( + canonicalAttachmentReference(attachmentMarkdownReference(path)), + path, + ); + final directory = await Directory.systemTemp.createTemp('notes-uri-'); + addTearDown(() => directory.delete(recursive: true)); + final client = NotesApiClient( + client: MockClient((request) async { + expect( + request.url.path, + '/nextcloud/index.php/apps/notes/api/v1.4/attachment/1', + ); + expect(request.url.queryParameters['path'], path); + return http.Response.bytes([1, 2, 3], 200); + }), + account: testAccount(), + appPassword: 'test', + ); + final file = await client.fetchAttachment( + 1, + path, + destination: File('${directory.path}/complete'), + ); + expect(await file.readAsBytes(), Uint8List.fromList([1, 2, 3])); + }); + } + test('encoded traversal and malformed destinations remain rejected', () { + for (final path in [ + '%2e%2e/a.png', + '%2Fetc/passwd', + '%00.png', + 'bad%.png', + ]) { + expect(canonicalAttachmentReference(path), isNull); + } + }); +} diff --git a/test/src/nextcloud_notes/notes_repository_test.dart b/test/src/nextcloud_notes/notes_repository_test.dart index a68ab2db..30baf409 100644 --- a/test/src/nextcloud_notes/notes_repository_test.dart +++ b/test/src/nextcloud_notes/notes_repository_test.dart @@ -712,7 +712,7 @@ void main() { ); expect((await store.notes()).single.content, 'body'); final db = sqlite3.open(path); - expect(db.select('PRAGMA user_version').single.values.single, 2); + expect(db.select('PRAGMA user_version').single.values.single, 6); expect(db.select('SELECT COUNT(*) AS n FROM outbox').single['n'], 1); db.close(); if (Platform.isLinux) { @@ -747,7 +747,7 @@ void main() { expect(repository.noteById(note.localId)!.revision, 10); expect(repository.noteById(note.localId)!.hasPendingChanges, isTrue); await repository.synchronize(testAccount().id); - expect(repository.noteById(note.localId)!.syncState, NoteSyncState.offline); + expect(repository.noteById(note.localId)!.syncState, NoteSyncState.pending); expect(repository.noteById(note.localId)!.content, 'revision 10'); }); diff --git a/test/src/nextcloud_notes/notes_retrieval_test.dart b/test/src/nextcloud_notes/notes_retrieval_test.dart new file mode 100644 index 00000000..bae4b87b --- /dev/null +++ b/test/src/nextcloud_notes/notes_retrieval_test.dart @@ -0,0 +1,450 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:busymark/src/nextcloud_notes/application/notes_repository.dart'; +import 'package:busymark/src/nextcloud_notes/data/notes_api_client.dart'; +import 'package:busymark/src/nextcloud_notes/data/notes_store.dart'; +import 'package:busymark/src/nextcloud_notes/data/server_uri.dart'; +import 'package:busymark/src/nextcloud_notes/domain/notes_models.dart'; +import 'package:busymark/src/nextcloud_notes/domain/notes_retrieval.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; + +import 'notes_api_test.dart' show serverNote, testAccount; + +void main() { + NotesApiClient api(Future Function(http.Request) handler) => + NotesApiClient( + client: MockClient(handler), + account: testAccount( + listEtag: '"collection"', + lastModified: 'Wed, 01 Jul 2026 00:00:00 GMT', + ), + appPassword: 'fixture', + ); + + test( + 'excluded identifier remains omitted across filtered pagination', + () async { + var requests = 0; + final scope = NotesRetrievalScope( + category: 'Scope', + exclude: {'id', 'content'}, + ); + final result = await api((request) async { + expect(request.url.queryParameters['category'], 'Scope'); + expect(request.url.queryParameters['exclude'], 'content,id'); + expect(request.headers.containsKey('If-None-Match'), isFalse); + return requests++ == 0 + ? http.Response( + jsonEncode([ + {'title': 'A', 'etag': 'a'}, + ]), + 200, + headers: {'X-Notes-Chunk-Cursor': 'next'}, + ) + : http.Response( + jsonEncode([ + {'title': 'B', 'etag': 'b'}, + {'id': 1}, + ]), + 200, + ); + }).query(scope, chunkSize: 1); + expect(result.notes, hasLength(2)); + expect(result.notes.map((n) => n.id), everyElement(isNull)); + expect(result.notes.map((n) => n.has('content')), everyElement(isFalse)); + }, + ); + + test( + 'web route observes explicit configuration without credentials or redirects', + () async { + for (final pretty in [true, false]) { + final result = await api((request) async { + expect( + request.url.path, + '${testAccount().server.path}/index.php/login', + ); + expect(request.followRedirects, isFalse); + expect(request.headers.containsKey('Authorization'), isFalse); + return http.Response( + '', + 200, + ); + }).noteWebPrettyUrls(); + expect(result, pretty); + expect( + nextcloudNoteUrl(testAccount().server, 42, pretty: result).path, + '${testAccount().server.path}${pretty ? '' : '/index.php'}/apps/notes/note/42', + ); + } + await expectLater( + api( + (_) async => http.Response( + '', + 302, + headers: {'location': 'https://evil.example'}, + ), + ).noteWebPrettyUrls(), + throwsA(isA()), + ); + }, + ); + test( + 'optional web observation round trips without changing schema or checkpoint', + () { + final original = testAccount(listEtag: '"checkpoint"'); + final restored = NextcloudAccount.fromJson( + original.copyWith(notesWebPrettyUrls: true).toJson(), + ); + expect(restored.notesWebPrettyUrls, isTrue); + expect(restored.listEtag, original.listEtag); + expect( + NextcloudAccount.fromJson(original.toJson()).notesWebPrettyUrls, + isNull, + ); + }, + ); + test( + 'conditional 200/304 retains complete representation and quoted validator', + () async { + final cached = NoteState.fromJson(serverNote(1)); + final client = api((request) async { + expect(request.headers['If-None-Match'], '"abc"'); + return http.Response('', 304); + }); + final result = await client.retrieve(1, cached: cached); + expect(result, isA()); + expect(identical((result as NotesNotModified).cached, cached), isTrue); + final returned = await api((request) async { + expect(request.headers.containsKey('If-None-Match'), isFalse); + return http.Response(jsonEncode(serverNote(1)), 200); + }).retrieve(1); + expect(returned, isA()); + }, + ); + + test( + '304 without cache, wrong identity and malformed full response fail', + () async { + for (final response in [ + http.Response('', 304), + http.Response(jsonEncode(serverNote(2)), 200), + http.Response('{"id":1}', 200), + ]) { + await expectLater( + api((_) async => response).retrieve(1), + throwsA(isA()), + ); + } + }, + ); + + test( + 'filtered projected pagination preserves parameters and omissions', + () async { + var calls = 0; + final scope = NotesRetrievalScope( + category: 'café/中文', + exclude: {'content', 'title'}, + ); + final result = await api((request) async { + expect(request.url.queryParameters['category'], 'café/中文'); + expect(request.url.queryParameters['exclude'], 'content,title'); + expect(request.url.queryParameters.containsKey('pruneBefore'), isFalse); + expect(request.headers.containsKey('If-None-Match'), isFalse); + if (calls++ == 0) { + return http.Response( + '[{"id":1,"category":"café/中文"}]', + 200, + headers: { + 'X-Notes-Chunk-Cursor': 'next', + 'content-type': 'application/json; charset=utf-8', + }, + ); + } + expect(request.url.queryParameters['chunkCursor'], 'next'); + return http.Response('[{"id":2,"favorite":true},{"id":1}]', 200); + }).query(scope, chunkSize: 1); + expect(identical(result.scope, scope), isTrue); + expect(result.notes.map((n) => n.id), [1, 2]); + expect(result.notes.first.has('content'), isFalse); + expect(result.notes.first.values['category'], 'café/中文'); + expect(result.notes.last.has('readonly'), isFalse); + }, + ); + + test('projected individual response retains omitted fields', () async { + final result = await api((request) async { + expect(request.url.queryParameters['exclude'], 'content'); + return http.Response('{"id":1,"title":"Title"}', 200); + }).getProjection(1, NotesRetrievalScope(exclude: {'content'})); + expect(result.has('content'), isFalse); + }); + + test( + 'query malformed fields, repeated cursor and authentication are scoped', + () async { + for (final response in [ + http.Response('[{"id":1,"title":null}]', 200), + http.Response('bad', 200), + http.Response('[]', 401), + http.Response('[]', 200, headers: {'X-Notes-Chunk-Cursor': 'repeat'}), + ]) { + await expectLater( + api((_) async => response).query(NotesRetrievalScope()), + throwsA( + isA().having( + (e) => e.scope, + 'scope', + NotesRequestScope.collection, + ), + ), + ); + } + }, + ); + + test('portable URLs retain installation boundaries and real IDs', () { + for (final prefix in ['', '/nextcloud', '/cloud%20space/中文']) { + final server = normalizeNextcloudServer('https://cloud.example$prefix'); + final url = nextcloudNoteUrl(server, 42); + expect(nextcloudNoteId(server, url.toString()), 42); + expect( + nextcloudNoteId(server, url.toString().replaceFirst('index.php/', '')), + 42, + ); + expect(nextcloudNoteId(server, '$url#actual-id'), 42); + for (final bad in [ + url.toString().replaceFirst('cloud.example', 'cloud.example.evil'), + url.toString().replaceFirst('/apps/', '/apps-extra/'), + '$url/more', + url.toString().replaceFirst('/note/42', '/note/%34%32'), + url.toString().replaceFirst('https:', 'http:'), + ]) { + expect(nextcloudNoteId(server, bad), isNull, reason: bad); + } + } + }); + + late Directory root; + late NotesRepository repository; + late Completer pending; + setUp(() async { + root = await Directory.systemTemp.createTemp('notes-retrieval-'); + final store = await NotesStore.open(path: '${root.path}/notes.sqlite3'); + pending = Completer(); + repository = NotesRepository( + store: store, + clientForAccount: (account) async => NotesApiClient( + client: MockClient((_) => pending.future), + account: account, + appPassword: 'fixture', + ), + ); + await repository.initialize(); + await repository.upsertAccount(testAccount(listEtag: '"checkpoint"')); + final state = NoteState.fromJson(serverNote(1)); + await store.saveNote( + NextcloudNote( + localId: 'cached', + accountId: testAccount().id, + serverId: 1, + content: state.content, + title: state.title, + category: state.category, + etag: state.etag, + base: state, + revision: 1, + ackRevision: 1, + ), + ); + // Load the durable representation through the ordinary repository path. + await repository.dispose(); + final reopened = await NotesStore.open(path: '${root.path}/notes.sqlite3'); + repository = NotesRepository( + store: reopened, + clientForAccount: (account) async => NotesApiClient( + client: MockClient((_) => pending.future), + account: account, + appPassword: 'fixture', + ), + ); + await repository.initialize(); + }); + tearDown(() async { + await repository.dispose(); + await root.delete(recursive: true); + }); + + test( + '304 after local edit leaves content, pending revision and checkpoint intact', + () async { + final request = repository.refreshLinkedNote(testAccount().id, 1); + final saved = await repository.save('cached', content: 'new local'); + pending.complete(http.Response('', 304)); + final result = await request; + expect(result.content, 'new local'); + expect(result.revision, saved.revision); + expect(result.ackRevision, 1); + expect(result.hasPendingChanges, isTrue); + expect( + repository.accountById(testAccount().id)!.listEtag, + '"checkpoint"', + ); + }, + ); + + test( + '200 during edit records conflict without replacing newer local text', + () async { + final request = repository.refreshLinkedNote(testAccount().id, 1); + await repository.save('cached', content: 'new local'); + pending.complete( + http.Response( + jsonEncode(serverNote(1, content: 'remote', etag: 'changed')), + 200, + ), + ); + final result = await request; + expect(result.content, 'new local'); + expect(result.remote!.content, 'remote'); + expect(result.syncState, NoteSyncState.conflict); + expect( + repository.accountById(testAccount().id)!.listEtag, + '"checkpoint"', + ); + }, + ); + + test('cached offline navigation starts no request', () async { + final result = await repository.linkedNote(testAccount().id, 1); + expect(result.localId, 'cached'); + expect(pending.isCompleted, isFalse); + }); + test( + 'held individual GET cannot replace later acknowledged PUTs after restart', + () async { + await repository.dispose(); + final putStarted = Completer(); + final getStarted = Completer(); + final firstAcknowledgment = Completer(); + final heldGet = Completer(); + var remote = serverNote(1); + var puts = 0; + http.Response? oldGet; + Future reopen() async { + final store = await NotesStore.open(path: '${root.path}/notes.sqlite3'); + final result = NotesRepository( + store: store, + clientForAccount: (account) async => NotesApiClient( + account: account, + appPassword: 'fixture', + client: MockClient((request) async { + if (request.method == 'PUT') { + final body = jsonDecode(request.body) as Map; + remote = {...serverNote(1), ...body, 'etag': body['content']}; + if (puts++ == 0) { + putStarted.complete(); + return firstAcknowledgment.future; + } + return http.Response(jsonEncode(remote), 200); + } + if (request.url.path.endsWith('/notes/1')) { + oldGet = http.Response(jsonEncode(remote), 200); + getStarted.complete(); + return heldGet.future; + } + return http.Response(jsonEncode([remote]), 200); + }), + ), + ); + await result.initialize(); + return result; + } + + repository = await reopen(); + final editBase = repository.editorBase('cached'); + await repository.save('cached', content: 'B'); + final publishingB = repository.synchronize(testAccount().id); + await putStarted.future; + final readingB = repository.refreshLinkedNote(testAccount().id, 1); + await getStarted.future; + firstAcknowledgment.complete(http.Response(jsonEncode(remote), 200)); + await publishingB; + // An acknowledgment of our own save must not masquerade as an external + // download when a captured editor base is used for the next local edit. + final savedC = await repository.save( + 'cached', + content: 'C', + editBase: editBase, + ); + expect(savedC.syncState, NoteSyncState.pending); + await repository.synchronize(testAccount().id); + final acknowledgedC = repository.noteById('cached')!; + expect(acknowledgedC.content, 'C'); + expect(acknowledgedC.syncState, NoteSyncState.synced); + heldGet.complete(oldGet!); + final result = await readingB; + void check(NextcloudNote note) { + expect(note.content, 'C'); + expect(note.base!.toJson(), acknowledgedC.base!.toJson()); + expect(note.base!.content, 'C'); + expect(note.etag, 'C'); + expect(note.base!.etag, 'C'); + expect(note.revision, savedC.revision); + expect(note.ackRevision, savedC.revision); + expect(note.syncState, NoteSyncState.synced); + expect(note.hasPendingChanges, isFalse); + expect(note.remote, isNull); + } + + check(result); + check(repository.noteById('cached')!); + await repository.dispose(); + repository = await reopen(); + check(repository.noteById('cached')!); + expect(puts, 2); + }, + ); + + test('account removal rejects in-flight retrieval', () async { + final request = repository.refreshLinkedNote(testAccount().id, 1); + final assertion = expectLater(request, throwsA(isA())); + await repository.removeAccount(testAccount().id); + pending.complete(http.Response(jsonEncode(serverNote(1)), 200)); + await assertion; + expect(repository.notes, isEmpty); + }); + test( + 'scoped projection never replaces records or full-account checkpoint', + () async { + final request = repository.queryNotes( + testAccount().id, + NotesRetrievalScope(category: 'elsewhere', exclude: {'content'}), + ); + pending.complete(http.Response('[{"id":2,"title":"Other"}]', 200)); + final result = await request; + expect(result.notes.single.id, 2); + expect(repository.notes.map((n) => n.localId), ['cached']); + expect( + repository.accountById(testAccount().id)!.listEtag, + '"checkpoint"', + ); + }, + ); + test('account removal rejects an in-flight scoped projection', () async { + final request = repository.queryNotes( + testAccount().id, + NotesRetrievalScope(), + ); + final assertion = expectLater(request, throwsA(isA())); + await repository.removeAccount(testAccount().id); + pending.complete(http.Response('[{"id":2}]', 200)); + await assertion; + expect(repository.notes, isEmpty); + }); +} diff --git a/test/src/nextcloud_notes/notes_review_corrections_test.dart b/test/src/nextcloud_notes/notes_review_corrections_test.dart new file mode 100644 index 00000000..734d9ca1 --- /dev/null +++ b/test/src/nextcloud_notes/notes_review_corrections_test.dart @@ -0,0 +1,524 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:busymark/src/nextcloud_notes/application/notes_repository.dart'; +import 'package:busymark/src/nextcloud_notes/data/notes_api_client.dart'; +import 'package:busymark/src/nextcloud_notes/data/notes_attachment_references.dart'; +import 'package:busymark/src/nextcloud_notes/data/notes_store.dart'; +import 'package:busymark/src/nextcloud_notes/domain/notes_conflict.dart'; +import 'package:busymark/src/nextcloud_notes/domain/notes_models.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; + +import 'notes_api_test.dart' show testAccount, serverNote; + +class _Fixture { + late Directory directory; + late NotesRepository repository; + DateTime now = DateTime.utc(2026, 10, 9, 12); + final remotes = >{ + 1: {...serverNote(1), 'title': 'A'}, + }; + int writes = 0; + int reads = 0; + int uploads = 0; + int rejection = 0; + String? retryAfter; + final downloaded = []; + Future? writeGate; + late final transport = MockClient((request) async { + expect(request.headers['authorization'], startsWith('Basic ')); + final path = request.url.path; + const root = '/nextcloud/index.php/apps/notes/api/'; + if (path == '${root}v1/notes' && request.method == 'GET') { + reads++; + return http.Response( + jsonEncode(remotes.values.toList()), + 200, + headers: {'content-type': 'application/json; charset=utf-8'}, + ); + } + if (path == '${root}v1.4/attachment/1') { + if (request.method == 'GET') { + downloaded.add(request.url.queryParameters['path']!); + return http.Response.bytes([1, 2, 3], 200); + } + expect(request.method, 'POST'); + uploads++; + return http.Response( + jsonEncode('.attachments.1/caf\u00e9.png'), + 200, + headers: {'content-type': 'application/json; charset=utf-8'}, + ); + } + if (path == '${root}v1/notes' && request.method == 'POST') { + writes++; + final body = jsonDecode(request.body) as Map; + final id = remotes.keys.fold(1, (int a, b) => a > b ? a : b) + 1; + final remote = { + ...serverNote(id), + ...body, + 'id': id, + 'etag': 'created-$id', + }; + remotes[id] = remote; + return http.Response( + jsonEncode(remote), + 200, + headers: {'content-type': 'application/json; charset=utf-8'}, + ); + } + expect(path, '${root}v1/notes/1'); + if (request.method == 'GET') { + return http.Response( + jsonEncode(remotes[1]), + 200, + headers: {'content-type': 'application/json; charset=utf-8'}, + ); + } + expect(request.method, 'PUT'); + expect(request.headers['if-match'], '"${remotes[1]!['etag']}"'); + writes++; + await writeGate; + if (rejection != 0) { + return http.Response( + '{}', + rejection, + headers: {if (retryAfter != null) 'Retry-After': retryAfter!}, + ); + } + remotes[1] = { + ...remotes[1]!, + ...jsonDecode(request.body) as Map, + 'etag': 'write-$writes', + }; + return http.Response( + jsonEncode(remotes[1]), + 200, + headers: {'content-type': 'application/json; charset=utf-8'}, + ); + }); + + Future open() async { + repository = NotesRepository( + store: await NotesStore.open(path: '${directory.path}/notes.sqlite3'), + clock: () => now, + clientForAccount: (account) async => NotesApiClient( + client: transport, + account: account, + appPassword: 'fixture', + clock: () => now, + ), + ); + await repository.initialize(); + if (repository.accounts.isEmpty) { + await repository.upsertAccount(testAccount()); + } + } + + Future restart() async { + await repository.dispose(); + await open(); + } + + Future seed() async { + await repository.synchronize(testAccount().id, allowWrites: false); + return repository.notes.single.localId; + } +} + +void main() { + late _Fixture f; + setUp(() async { + f = _Fixture(); + f.directory = await Directory.systemTemp.createTemp('notes-review-'); + await f.open(); + }); + tearDown(() async { + await f.repository.dispose(); + f.transport.close(); + await f.directory.delete(recursive: true); + }); + + for (final restart in [false, true]) { + test( + 'verified reconnect resumes a 401-blocked write, restart=$restart', + () async { + final id = await f.seed(); + await f.repository.save(id, content: 'durable authenticated edit'); + f.rejection = 401; + await f.repository.synchronize(testAccount().id); + expect( + f.repository.noteById(id)!.syncState, + NoteSyncState.reconnectRequired, + ); + if (restart) await f.restart(); + f.rejection = 0; + await f.repository.upsertAccount(testAccount(), reconnect: true); + await f.repository.synchronize(testAccount().id); + expect(f.writes, 2); + expect(f.repository.noteById(id)!.syncState, NoteSyncState.synced); + expect(f.remotes[1]!['content'], 'durable authenticated edit'); + }, + ); + } + + test( + 'reconnect recovers legacy persisted authentication blocks without changing acknowledgments', + () async { + final id = await f.seed(); + await f.repository.save(id, content: 'persisted edit'); + f.rejection = 401; + await f.repository.synchronize(testAccount().id); + final blocked = f.repository.noteById(id)!; + await f.repository.store.saveNote( + blocked.copyWith(failureCode: null, failureScope: null), + ); + await f.restart(); + await f.repository.upsertAccount(testAccount(), reconnect: true); + expect(f.repository.noteById(id)!.ackRevision, blocked.ackRevision); + expect(f.repository.noteById(id)!.revision, blocked.revision); + f.rejection = 0; + await f.repository.synchronize(testAccount().id); + expect(f.repository.noteById(id)!.syncState, NoteSyncState.synced); + }, + ); + + test( + 'reconnection finishes an older credential request before releasing its block', + () async { + final id = await f.seed(); + await f.repository.save(id, content: 'pending across reconnect'); + f.rejection = 401; + final gate = Completer(); + f.writeGate = gate.future; + final sending = f.repository.synchronize(testAccount().id); + while (f.writes == 0) { + await Future.delayed(Duration.zero); + } + var reconnected = false; + final reconnect = f.repository + .upsertAccount(testAccount(), reconnect: true) + .then((_) => reconnected = true); + await f.repository.save( + id, + content: 'newer durable edit during reconnect', + ); + expect(reconnected, isFalse); + gate.complete(); + await sending; + await reconnect; + f.rejection = 0; + await f.repository.synchronize(testAccount().id); + expect(f.repository.noteById(id)!.syncState, NoteSyncState.synced); + expect(f.remotes[1]!['content'], 'newer durable edit during reconnect'); + }, + ); + + test( + 'reconnect preserves changed-server conflicts and uncertain creation/upload evidence', + () async { + final id = await f.seed(); + await f.repository.save(id, content: 'pending conflict'); + f.rejection = 401; + await f.repository.synchronize(testAccount().id); + final draft = await f.repository.create( + testAccount().id, + content: 'uncertain draft', + ); + final attempt = NotesCreationAttempt( + id: 'attempt', + revision: draft.revision, + localContent: draft.content, + wireBody: '{"content":"uncertain draft"}', + knownServerIds: {1}, + ); + await f.repository.store.saveNote( + draft.copyWith( + syncState: NoteSyncState.creationUncertain, + creationNeverSent: false, + creationAttempt: attempt, + ), + ); + final attachment = await f.repository.addAttachment( + id, + filename: 'a.png', + bytes: Uint8List.fromList([4, 5]), + ); + await f.repository.store.updateAttachment( + NotesAttachment( + id: attachment.id, + noteId: id, + filename: attachment.filename, + reference: attachment.reference, + state: 'uncertain', + ), + ); + await f.restart(); + f.remotes[1] = { + ...f.remotes[1]!, + 'title': 'real remote change', + 'etag': 'remote-change', + }; + f.rejection = 0; + await f.repository.upsertAccount(testAccount(), reconnect: true); + await f.repository.synchronize(testAccount().id); + expect(f.repository.noteById(id)!.syncState, NoteSyncState.conflict); + expect( + f.repository.noteById(draft.localId)!.syncState, + NoteSyncState.creationUncertain, + ); + expect( + f.repository.noteById(draft.localId)!.creationAttempt!.wireBody, + attempt.wireBody, + ); + expect( + (await f.repository.store.attachments(id)).single.state, + 'uncertain', + ); + expect(await f.repository.store.attachmentBytes(attachment.id), [4, 5]); + expect(f.writes, 1); + expect(f.uploads, 0); + }, + ); + + for (final draft in [false, true]) { + for (final resolution in [ + NoteConflictResolution.keepLocal, + NoteConflictResolution.takeRemote, + NoteConflictResolution.merge, + NoteConflictResolution.saveAsNew, + ]) { + test( + 'reviewed B/C metadata choice survives resolution and sync: draft=$draft $resolution', + () async { + final id = draft + ? (await f.repository.create( + testAccount().id, + title: 'A', + content: 'body', + )).localId + : await f.seed(); + final snapshot = f.repository.metadataSnapshot(id); + await f.repository.patchMetadata(snapshot, title: 'B'); + await f.repository.patchMetadata(snapshot, title: 'C'); + final conflict = f.repository.noteById(id)!; + expect(conflict.syncState, NoteSyncState.conflict); + expect(conflict.title, 'C'); + await f.restart(); + await f.repository.resolveConflict( + id, + resolution, + expectedRevision: conflict.revision, + metadataChoices: { + NotesMergeAttribute.title: NotesMergeChoice.remote, + }, + ); + final expected = + resolution == NoteConflictResolution.keepLocal || + resolution == NoteConflictResolution.saveAsNew + ? 'C' + : 'B'; + final selected = resolution == NoteConflictResolution.saveAsNew + ? f.repository.notes.singleWhere( + (n) => n.localId != id && n.serverId == null, + ) + : f.repository.noteById(id)!; + expect(selected.title, expected); + expect(selected.content, 'body'); + await f.repository.synchronize(testAccount().id); + expect(f.repository.noteById(selected.localId)!.title, expected); + expect( + f.repository.noteById(selected.localId)!.syncState, + NoteSyncState.synced, + ); + if (resolution == NoteConflictResolution.saveAsNew) { + expect( + f.repository.noteById(id)!.syncState, + NoteSyncState.conflict, + ); + } + }, + ); + } + } + + for (final draft in [false, true]) { + test('metadata merge explicitly selects C, draft=$draft', () async { + final id = draft + ? (await f.repository.create( + testAccount().id, + title: 'A', + content: 'body', + )).localId + : await f.seed(); + final snapshot = f.repository.metadataSnapshot(id); + await f.repository.patchMetadata(snapshot, title: 'B'); + await f.repository.patchMetadata(snapshot, title: 'C'); + await f.repository.resolveConflict( + id, + NoteConflictResolution.merge, + metadataChoices: {NotesMergeAttribute.title: NotesMergeChoice.local}, + ); + await f.repository.synchronize(testAccount().id); + expect(f.repository.noteById(id)!.title, 'C'); + expect(f.repository.noteById(id)!.syncState, NoteSyncState.synced); + }); + } + + test( + 'actual server changes retain the selected local alternative for another review', + () async { + final id = await f.seed(); + final snapshot = f.repository.metadataSnapshot(id); + await f.repository.patchMetadata(snapshot, title: 'B'); + await f.repository.patchMetadata(snapshot, title: 'C'); + f.remotes[1] = {...f.remotes[1]!, 'title': 'D', 'etag': 'changed-again'}; + await f.repository.resolveConflict(id, NoteConflictResolution.takeRemote); + final chosen = f.repository.noteById(id)!; + expect(chosen.title, 'B'); + expect(chosen.remote!.title, 'D'); + expect(chosen.syncState, NoteSyncState.conflict); + await f.repository.synchronize(testAccount().id); + expect(f.writes, 0); + await f.repository.resolveConflict(id, NoteConflictResolution.keepLocal); + await f.repository.synchronize(testAccount().id); + expect(f.remotes[1]!['title'], 'B'); + }, + ); + + test( + 'take-server cannot silently substitute a value changed after review', + () async { + final id = await f.seed(); + await f.repository.save(id, content: 'local work'); + f.remotes[1] = { + ...f.remotes[1]!, + 'title': 'reviewed server', + 'etag': 'second', + }; + await f.repository.synchronize(testAccount().id, allowWrites: false); + f.remotes[1] = { + ...f.remotes[1]!, + 'title': 'unreviewed server', + 'etag': 'third', + }; + await expectLater( + f.repository.resolveConflict(id, NoteConflictResolution.takeRemote), + throwsA( + isA().having( + (e) => e.code, + 'code', + NotesFailureCode.conflict, + ), + ), + ); + expect(f.repository.noteById(id)!.content, 'local work'); + expect(f.repository.noteById(id)!.remote!.title, 'unreviewed server'); + }, + ); + + test('local metadata review revalidates read-only permission', () async { + final id = await f.seed(); + final snapshot = f.repository.metadataSnapshot(id); + await f.repository.patchMetadata(snapshot, title: 'B'); + await f.repository.patchMetadata(snapshot, title: 'C'); + f.remotes[1] = {...f.remotes[1]!, 'readonly': true}; + await expectLater( + f.repository.resolveConflict(id, NoteConflictResolution.takeRemote), + throwsA( + isA().having( + (e) => e.code, + 'code', + NotesFailureCode.forbidden, + ), + ), + ); + expect(f.repository.noteById(id)!.metadataConflict!.alternative.title, 'B'); + }); + + for (final metadata in [false, true]) { + test( + 'accepted edit retains 429 deadline through restart: metadata=$metadata', + () async { + final id = await f.seed(); + await f.repository.save(id, content: 'first edit'); + f.rejection = 429; + f.retryAfter = '600'; + await f.repository.synchronize(testAccount().id); + final deadline = f.now.add(const Duration(minutes: 10)); + f.rejection = 0; + f.now = f.now.add(const Duration(minutes: 1)); + if (metadata) { + await f.repository.patchMetadata( + f.repository.metadataSnapshot(id), + title: 'new metadata', + ); + } else { + await f.repository.save(id, content: 'new content'); + } + expect(f.repository.noteById(id)!.retryNotBefore, deadline); + await f.repository.synchronize(testAccount().id); + expect(f.writes, 1); + await f.restart(); + await f.repository.synchronize(testAccount().id); + expect(f.writes, 1); + f.now = deadline.subtract(const Duration(microseconds: 1)); + await f.repository.synchronize(testAccount().id); + expect(f.writes, 1); + f.now = deadline; + await f.repository.synchronize(testAccount().id); + expect(f.writes, 2); + expect(f.repository.noteById(id)!.syncState, NoteSyncState.synced); + }, + ); + } + + test( + 'literal Unicode HTML attachment resolves at the URI boundary', + () async { + f.remotes[1] = { + ...f.remotes[1]!, + 'content': '', + }; + final id = await f.seed(); + final refs = notesAttachmentReferences( + f.repository.noteById(id)!.content, + ); + expect(refs.single.reference, '.attachments.1/caf\u00e9.png'); + for (final ref in [ + '.attachments.1/caf\u00e9.png', + '.attachments.1/caf%C3%A9.png', + ]) { + expect( + canonicalAttachmentReference(ref), + '.attachments.1/caf\u00e9.png', + ); + final file = await f.repository.resolveMedia(testAccount().id, id, ref); + expect(await File(file!).readAsBytes(), Uint8List.fromList([1, 2, 3])); + } + expect(f.downloaded, ['.attachments.1/caf\u00e9.png']); + expect( + canonicalAttachmentReference('.attachments.1/100%25.png'), + '.attachments.1/100%.png', + ); + expect( + canonicalAttachmentReference('.attachments.1/%252F.png'), + '.attachments.1/%2F.png', + ); + for (final unsafe in [ + '../caf\u00e9.png', + '.attachments.1/%2e%2e/caf%C3%A9.png', + '.attachments.1/bad%GG.png', + '.attachments.1/bad%', + '//foreign/image', + '.attachments.1/a%5Cb.png', + ]) { + expect(canonicalAttachmentReference(unsafe), isNull); + } + }, + ); +} diff --git a/test/src/nextcloud_notes/notes_settings_test.dart b/test/src/nextcloud_notes/notes_settings_test.dart new file mode 100644 index 00000000..cdc0adc0 --- /dev/null +++ b/test/src/nextcloud_notes/notes_settings_test.dart @@ -0,0 +1,251 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:busymark/l10n/generated/app_localizations.dart'; +import 'package:busymark/src/local_history/local_history_controller.dart'; +import 'package:busymark/src/local_history/local_history_store.dart'; +import 'package:busymark/src/nextcloud_notes/application/nextcloud_connection.dart'; +import 'package:busymark/src/nextcloud_notes/application/notes_repository.dart'; +import 'package:busymark/src/nextcloud_notes/application/notes_settings_controller.dart'; +import 'package:busymark/src/nextcloud_notes/data/notes_api_client.dart'; +import 'package:busymark/src/nextcloud_notes/data/notes_capabilities.dart'; +import 'package:busymark/src/nextcloud_notes/data/notes_store.dart'; +import 'package:busymark/src/nextcloud_notes/presentation/nextcloud_settings.dart'; +import 'package:busymark/src/workspace/recovery_persistence.dart'; +import 'package:busymark/src/workspace/session_persistence.dart'; +import 'package:busymark/src/workspace/workspace_controller.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; + +import 'notes_api_test.dart' show testAccount; + +void main() { + late Directory directory; + late NotesRepository repository; + late http.Client transport; + var widgetDisposed = false; + var gets = 0; + var capabilityChecks = 0; + final bodies = []; + var settings = {'notesPath': 'Notes', 'fileSuffix': '.txt'}; + setUp(() async { + widgetDisposed = false; + gets = 0; + capabilityChecks = 0; + bodies.clear(); + settings = {'notesPath': 'Notes', 'fileSuffix': '.txt'}; + directory = await Directory.systemTemp.createTemp('notes-settings-'); + transport = MockClient((request) async { + if (request.url.path.endsWith('/v1/notes')) { + expect(request.method, 'GET'); + return http.Response('[]', 200); + } + expect( + request.url.path, + '/nextcloud/index.php/apps/notes/api/v1/settings', + ); + expect(request.headers['authorization'], startsWith('Basic ')); + if (request.method == 'GET') { + gets++; + } else { + expect(request.method, 'PUT'); + final body = jsonDecode(request.body) as Map; + bodies.add(body); + settings = { + ...settings, + for (final entry in body.entries) + entry.key as String: (entry.value as String).trim(), + }; + } + return http.Response( + jsonEncode({...settings, 'editorMode': 'ignored'}), + 200, + ); + }); + repository = NotesRepository( + store: await NotesStore.open(path: '${directory.path}/notes.sqlite3'), + clientForAccount: (a) async => + NotesApiClient(client: transport, account: a, appPassword: 'fixture'), + ); + await repository.initialize(); + await repository.upsertAccount(testAccount()); + }); + tearDown(() async { + if (!widgetDisposed) await repository.dispose(); + directory.deleteSync(recursive: true); + }); + + test( + 'settings-only first connected use verifies maintained capabilities', + () async { + await repository.dispose(); + repository = NotesRepository( + store: await NotesStore.open(path: '${directory.path}/notes.sqlite3'), + clientForAccount: (a) async => NotesApiClient( + client: transport, + account: a, + appPassword: 'fixture', + ), + fetchCapabilities: (_) async { + capabilityChecks++; + return const NotesCapabilities( + appVersion: '6.1.0', + apiVersion: '1.4', + ); + }, + ); + await repository.initialize(); + await repository.upsertAccount(testAccount(appVersion: '6.0.2')); + final container = ProviderContainer( + overrides: [ + nextcloudNotesRepositoryProvider.overrideWith( + (_) async => repository, + ), + ], + ); + addTearDown(container.dispose); + final subscription = container.listen(notesSettingsProvider, (_, _) {}); + addTearDown(subscription.close); + final controller = container.read(notesSettingsProvider.notifier); + await controller.load(testAccount().id); + expect(capabilityChecks, 1); + expect(repository.accountById(testAccount().id)!.appVersion, '6.1.0'); + expect( + repository.accountById(testAccount().id)!.capabilitiesCheckedAt, + isNotNull, + ); + await controller.load(testAccount().id); + expect(capabilityChecks, 1); + expect(gets, 2); + }, + ); + + test( + 'dirty settings form ignores background reload and Cancel restores server snapshot', + () async { + final container = ProviderContainer( + overrides: [ + nextcloudNotesRepositoryProvider.overrideWith( + (_) async => repository, + ), + ], + ); + addTearDown(container.dispose); + final subscription = container.listen(notesSettingsProvider, (_, _) {}); + addTearDown(subscription.close); + final controller = container.read(notesSettingsProvider.notifier); + await controller.load(testAccount().id); + controller.edit(fileSuffix: '.custom'); + await controller.load(testAccount().id); + expect(gets, 1); + expect( + container.read(notesSettingsProvider).draft!.fileSuffix, + '.custom', + ); + controller.cancel(); + expect(container.read(notesSettingsProvider).draft!.fileSuffix, '.txt'); + expect(container.read(notesSettingsProvider).dirty, isFalse); + controller.edit(fileSuffix: ' .custom '); + expect(await controller.save(preserveBuffers: () async => true), isTrue); + expect(bodies.single, {'fileSuffix': ' .custom '}); + expect(container.read(notesSettingsProvider).normalized, isTrue); + expect( + container.read(notesSettingsProvider).draft!.fileSuffix, + '.custom', + ); + }, + ); + + testWidgets( + 'server settings controls load, save partial custom suffix and show normalization', + (tester) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + nextcloudNotesRepositoryProvider.overrideWith( + (_) async => repository, + ), + documentSessionStoreProvider.overrideWithValue( + MemoryDocumentSessionStore(), + ), + documentRecoveryStoreProvider.overrideWithValue( + MemoryDocumentRecoveryStore(), + ), + localHistoryStoreProvider.overrideWithValue( + MemoryLocalHistoryStore(), + ), + ], + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const Scaffold( + body: SingleChildScrollView(child: NextcloudNotesSettings()), + ), + ), + ), + ); + final container = ProviderScope.containerOf( + tester.element(find.byType(NextcloudNotesSettings)), + ); + await tester.runAsync(() async { + await container.read(nextcloudConnectionProvider.notifier).reload(); + }); + await tester.pumpAndSettle(); + await tester.runAsync(() async { + await container + .read(notesSettingsProvider.notifier) + .load(testAccount().id); + }); + await tester.pumpAndSettle(); + expect(find.text('Server Notes settings'), findsOneWidget); + final suffix = find.descendant( + of: find.byKey(const ValueKey('notes-settings-suffix')), + matching: find.byType(TextField), + ); + expect(tester.widget(suffix).controller!.text, '.txt'); + await tester.enterText(suffix, ' .custom '); + await tester.pump(); + await tester.ensureVisible( + find.byKey(const ValueKey('notes-settings-save')), + ); + await tester.tap(find.byKey(const ValueKey('notes-settings-save'))); + for ( + var i = 0; + i < 1000 && + container.read(notesSettingsProvider).phase != + NotesSettingsPhase.saved; + i++ + ) { + await tester.pump(const Duration(milliseconds: 20)); + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 10)), + ); + } + expect( + container.read(notesSettingsProvider).phase, + NotesSettingsPhase.saved, + ); + await tester.pumpAndSettle(); + expect(bodies.single, {'fileSuffix': ' .custom '}); + expect(find.text('Saved with server-normalized values'), findsOneWidget); + expect(tester.widget(suffix).controller!.text, '.custom'); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox.shrink()); + var disposed = false; + unawaited(repository.dispose().then((_) => disposed = true)); + for (var i = 0; i < 1000 && !disposed; i++) { + await tester.pump(const Duration(milliseconds: 20)); + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 10)), + ); + } + widgetDisposed = + true; // Disposal is verified here in the widget test zone. + expect(disposed, isTrue); + }, + ); +} diff --git a/test/src/nextcloud_notes/notes_sync_coordinator_test.dart b/test/src/nextcloud_notes/notes_sync_coordinator_test.dart new file mode 100644 index 00000000..f425c963 --- /dev/null +++ b/test/src/nextcloud_notes/notes_sync_coordinator_test.dart @@ -0,0 +1,405 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:busymark/src/nextcloud_notes/application/notes_repository.dart'; +import 'package:busymark/src/nextcloud_notes/application/notes_sync_coordinator.dart'; +import 'package:busymark/src/nextcloud_notes/data/notes_api_client.dart'; +import 'package:busymark/src/nextcloud_notes/data/notes_store.dart'; +import 'package:busymark/src/nextcloud_notes/domain/notes_models.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; + +import 'notes_api_test.dart' show testAccount, serverNote; + +class _Task implements NotesScheduledTask { + _Task(this.deadline, this.callback); + final DateTime deadline; + final void Function() callback; + bool cancelled = false; + @override + void cancel() => cancelled = true; +} + +class _Timers { + DateTime now = DateTime.utc(2026, 9, 1); + final tasks = <_Task>[]; + NotesScheduledTask schedule(Duration delay, void Function() callback) { + final task = _Task(now.add(delay), callback); + tasks.add(task); + return task; + } + + void advance(Duration duration) { + now = now.add(duration); + final ready = tasks + .where((t) => !t.cancelled && !t.deadline.isAfter(now)) + .toList(); + for (final task in ready) { + task.cancelled = true; + task.callback(); + } + tasks.removeWhere((t) => t.cancelled); + } +} + +void main() { + late Directory directory; + late _Timers timers; + NotesRepository? repository; + NotesSyncCoordinator? coordinator; + setUp(() async { + directory = await Directory.systemTemp.createTemp('notes-scheduler-'); + timers = _Timers(); + }); + tearDown(() async { + coordinator?.dispose(); + await repository?.dispose(); + repository = null; + coordinator = null; + await directory.delete(recursive: true); + }); + Future open(http.Client transport) async { + final r = NotesRepository( + store: await NotesStore.open(path: '${directory.path}/notes.sqlite3'), + clock: () => timers.now, + clientForAccount: (a) async => NotesApiClient( + client: transport, + account: a, + appPassword: 'fixture', + clock: () => timers.now, + ), + ); + await r.initialize(); + await r.upsertAccount(testAccount()); + repository = r; + final c = NotesSyncCoordinator( + r, + clock: () => timers.now, + schedule: timers.schedule, + ); + c.setActiveAccount(testAccount().id); + coordinator = c; + return c; + } + + Future advance(NotesSyncCoordinator c, Duration duration) async { + timers.advance(duration); + await c.request(NotesSyncTrigger.focus); + } + + test( + 'healthy read publishes fresh durable work without releasing exhausted writes', + () async { + var puts = 0; + var creates = 0; + final c = await open( + MockClient((request) async { + expect(request.headers['authorization'], startsWith('Basic ')); + if (request.method == 'GET') { + expect( + request.url.path, + '/nextcloud/index.php/apps/notes/api/v1/notes', + ); + return http.Response(jsonEncode([serverNote(1)]), 200); + } + if (request.method == 'PUT') { + expect( + request.url.path, + '/nextcloud/index.php/apps/notes/api/v1/notes/1', + ); + puts++; + return http.Response('{}', 423); + } + expect(request.method, 'POST'); + expect( + request.url.path, + '/nextcloud/index.php/apps/notes/api/v1/notes', + ); + creates++; + return http.Response( + jsonEncode({...serverNote(2), ...jsonDecode(request.body) as Map}), + 200, + ); + }), + ); + await c.request(NotesSyncTrigger.opening); + await repository!.save( + repository!.notes.single.localId, + content: 'locked edit', + ); + for (var i = 0; i < 7; i++) { + await repository!.synchronize(testAccount().id); + } + expect(puts, 7); + final fresh = await repository!.create( + testAccount().id, + content: 'saved while screen inactive', + ); + await c.request(NotesSyncTrigger.poll); + expect(puts, 7); + expect(creates, 1); + expect( + repository!.noteById(fresh.localId)!.syncState, + NoteSyncState.synced, + ); + expect( + repository!.notes.firstWhere((n) => n.serverId == 1).retryCount, + 7, + ); + }, + ); + + test( + 'healthy idle discovery, visible focus, hidden suspension and disposal use controlled timers', + () async { + var gets = 0; + var content = 'first'; + final c = await open( + MockClient((request) async { + expect(request.method, 'GET'); + expect(request.url.path.endsWith('/v1/notes'), isTrue); + gets++; + return http.Response( + jsonEncode([serverNote(1, content: content, etag: 'etag-$gets')]), + 200, + ); + }), + ); + await c.request(NotesSyncTrigger.opening); + expect(gets, 1); + content = 'changed remotely'; + await advance(c, const Duration(seconds: 60)); + expect(repository!.notes.single.content, content); + expect(gets, 2); + c.focused(); + await c.request(NotesSyncTrigger.focus); + expect(gets, 2); + c.setVisible(false); + await advance(c, const Duration(minutes: 10)); + expect(gets, 2); + c.setVisible(true); + await c.request(NotesSyncTrigger.focus); + expect(gets, 3); + c.setVisible(true); + await advance(c, const Duration(seconds: 60)); + expect(gets, 4); + c.dispose(); + timers.advance(const Duration(hours: 1)); + expect(gets, 4); + expect(timers.tasks, isEmpty); + }, + ); + + test( + 'manual refresh awaits one coalesced followup when a local save arrives during GET', + () async { + final started = Completer(); + final reply = Completer(); + var gets = 0; + var puts = 0; + var delay = false; + var remote = serverNote(1); + final c = await open( + MockClient((request) async { + if (request.method == 'GET') { + gets++; + if (delay) { + delay = false; + started.complete(); + await reply.future; + } + return http.Response(jsonEncode([remote]), 200); + } + expect(request.method, 'PUT'); + puts++; + remote = { + ...remote, + ...jsonDecode(request.body) as Map, + 'etag': 'written', + }; + return http.Response(jsonEncode(remote), 200); + }), + ); + await c.request(NotesSyncTrigger.opening); + delay = true; + final manual = c.request(NotesSyncTrigger.manual); + await started.future; + await repository!.save( + repository!.notes.single.localId, + content: 'saved during GET', + ); + final followup = c.request(NotesSyncTrigger.localChange); + c.focused(); + var completed = false; + unawaited( + manual.then((_) { + completed = true; + }), + ); + expect(completed, isFalse); + reply.complete(); + await Future.wait([manual, followup]); + expect(completed, isTrue); + expect(gets, 3); + expect(puts, 1); + expect(repository!.notes.single.syncState, NoteSyncState.synced); + }, + ); + + test( + 'six locked write retries exhaust; healthy reads and focus do not replenish the budget', + () async { + var puts = 0; + final c = await open( + MockClient((request) async { + if (request.method == 'GET') { + return http.Response(jsonEncode([serverNote(1)]), 200); + } + expect(request.method, 'PUT'); + puts++; + return http.Response('{}', 423); + }), + ); + await c.request(NotesSyncTrigger.opening); + await repository!.save( + repository!.notes.single.localId, + content: 'locked local edit', + ); + await c.request(NotesSyncTrigger.localChange); + for (final seconds in NotesSyncCoordinator.retryIntervals) { + await advance(c, Duration(seconds: seconds)); + } + expect(puts, 7); + expect(repository!.notes.single.retryCount, 7); + for (var i = 0; i < 4; i++) { + await advance(c, const Duration(minutes: 1)); + } + expect(puts, 7); + await c.request(NotesSyncTrigger.manual); + expect(puts, 8); + }, + ); + + test( + 'throttling schedules at the server deadline, even beyond normal retry intervals', + () async { + var puts = 0; + final c = await open( + MockClient((request) async { + if (request.method == 'GET') { + return http.Response(jsonEncode([serverNote(1)]), 200); + } + puts++; + return http.Response('{}', 429, headers: {'Retry-After': '3600'}); + }), + ); + await c.request(NotesSyncTrigger.opening); + await repository!.save( + repository!.notes.single.localId, + content: 'throttled', + ); + await c.request(NotesSyncTrigger.localChange); + for (var i = 0; i < 59; i++) { + await advance(c, const Duration(minutes: 1)); + } + expect(puts, 1); + await advance(c, const Duration(minutes: 1)); + expect(puts, 2); + }, + ); + + test( + 'authenticated recovery reads restore network work but never retry uncertain creations', + () async { + var offline = false; + var posts = 0; + final c = await open( + MockClient((request) async { + if (offline) throw http.ClientException('fixture offline'); + if (request.method == 'GET') return http.Response('[]', 200); + expect(request.method, 'POST'); + posts++; + throw http.ClientException('fixture lost creation response'); + }), + ); + await c.request(NotesSyncTrigger.opening); + final draft = await repository!.create( + testAccount().id, + content: 'draft', + ); + offline = true; + await c.request(NotesSyncTrigger.localChange); + expect(posts, 0); + offline = false; + await advance(c, const Duration(minutes: 1)); + expect(posts, 1); + expect( + repository!.noteById(draft.localId)!.syncState, + NoteSyncState.creationUncertain, + ); + await advance(c, const Duration(minutes: 1)); + await c.request(NotesSyncTrigger.manual); + expect(posts, 1); + c.removeAccount(testAccount().id); + timers.advance(const Duration(hours: 1)); + expect(posts, 1); + }, + ); + + test( + 'edited throttled work keeps a deadline timer after restart while hidden', + () async { + var puts = 0; + var remote = serverNote(1); + final transport = MockClient((request) async { + if (request.method == 'GET') { + expect(request.url.path.endsWith('/v1/notes'), isTrue); + return http.Response(jsonEncode([remote]), 200); + } + expect(request.method, 'PUT'); + expect(request.url.path.endsWith('/v1/notes/1'), isTrue); + puts++; + if (puts == 1) { + return http.Response('{}', 429, headers: {'Retry-After': '600'}); + } + remote = { + ...remote, + ...jsonDecode(request.body) as Map, + 'etag': 'written', + }; + return http.Response(jsonEncode(remote), 200); + }); + var c = await open(transport); + await c.request(NotesSyncTrigger.opening); + final id = repository!.notes.single.localId; + await repository!.save(id, content: 'first edit'); + await c.request(NotesSyncTrigger.localChange); + await repository!.patchMetadata( + repository!.metadataSnapshot(id), + title: 'edited while throttled', + ); + expect(repository!.noteById(id)!.retryCount, 0); + await c.request(NotesSyncTrigger.localChange); + expect(puts, 1); + c.dispose(); + await repository!.dispose(); + c = await open(transport); + c.setVisible(false); + await c.request(NotesSyncTrigger.opening); + expect( + timers.tasks.where((t) => !t.cancelled).single.deadline, + timers.now.add(const Duration(minutes: 10)), + ); + timers.advance(const Duration(minutes: 9)); + expect(puts, 1); + timers.advance(const Duration(minutes: 1)); + // Join the pass started by the deadline timer without making another trigger. + await repository!.synchronize(testAccount().id); + expect(puts, 2); + expect(repository!.noteById(id)!.syncState, NoteSyncState.synced); + expect(remote['title'], 'edited while throttled'); + }, + ); +} diff --git a/test/src/nextcloud_notes/notes_workspace_m2_test.dart b/test/src/nextcloud_notes/notes_workspace_m2_test.dart new file mode 100644 index 00000000..5d19b31b --- /dev/null +++ b/test/src/nextcloud_notes/notes_workspace_m2_test.dart @@ -0,0 +1,1722 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:busymark/src/nextcloud_notes/application/notes_navigation.dart'; +import 'package:busymark/src/nextcloud_notes/application/notes_offline_controller.dart'; +import 'package:busymark/src/nextcloud_notes/application/notes_repository.dart'; +import 'package:busymark/src/nextcloud_notes/application/notes_search_controller.dart'; +import 'package:busymark/src/nextcloud_notes/application/notes_transfer_service.dart'; +import 'package:busymark/src/nextcloud_notes/data/notes_api_client.dart'; +import 'package:busymark/src/nextcloud_notes/data/notes_store.dart'; +import 'package:busymark/src/nextcloud_notes/domain/notes_models.dart'; +import 'package:busymark/src/nextcloud_notes/domain/notes_search.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:path/path.dart' as p; +import 'package:sqlite3/sqlite3.dart'; + +import 'notes_api_test.dart' show testAccount, serverNote; + +class _Fixture { + late Directory root; + late NotesRepository repository; + bool offline = false, failMedia = false; + bool loseNextCreate = false, loseNextUpload = false; + Completer? mediaGate, writeGate; + final writeStarted = Completer(); + final mediaStarted = Completer(); + int downloads = 0, writes = 0, uploads = 0; + final remote = >{}; + late final transport = MockClient((request) async { + if (offline) throw http.ClientException('offline fixture'); + if (request.url.path.contains('/attachment/')) { + final id = int.parse(request.url.path.split('/').last); + if (request.method == 'GET') { + downloads++; + if (!mediaStarted.isCompleted) mediaStarted.complete(); + await mediaGate?.future; + if (failMedia) return http.Response('missing', 404); + return http.Response.bytes([1, 2, 3], 200); + } + if (request.method == 'POST') { + uploads++; + if (loseNextUpload) { + loseNextUpload = false; + throw http.ClientException('lost upload response'); + } + } + return http.Response( + jsonEncode({'filename': '.attachments.$id/café%20.png'}), + 200, + headers: {'content-type': 'application/json; charset=utf-8'}, + ); + } + final id = int.tryParse(request.url.path.split('/').last); + if (request.method == 'GET') { + return http.Response( + jsonEncode(id == null ? remote.values.toList() : remote[id]), + 200, + headers: {'content-type': 'application/json; charset=utf-8'}, + ); + } + if (request.method == 'DELETE') { + remote.remove(id); + return http.Response('', 200); + } + writes++; + if (!writeStarted.isCompleted) writeStarted.complete(); + await writeGate?.future; + final body = jsonDecode(request.body) as Map; + final assigned = id ?? remote.length + 1; + if (id != null && remote[id]?['readonly'] == true) { + expect(body.keys, ['favorite']); + } + remote[assigned] = { + ...serverNote(assigned), + ...remote[assigned] ?? {}, + ...body, + 'etag': 'write-$writes', + }; + if (id == null && loseNextCreate) { + loseNextCreate = false; + throw http.ClientException('lost creation response'); + } + return http.Response( + jsonEncode(remote[assigned]), + 200, + headers: {'content-type': 'application/json; charset=utf-8'}, + ); + }); + Future open() async { + repository = NotesRepository( + store: await NotesStore.open(path: p.join(root.path, 'notes.db')), + clientForAccount: (account) async => NotesApiClient( + client: transport, + account: account, + appPassword: 'fixture', + ), + ); + await repository.initialize(); + if (repository.accounts.isEmpty) { + await repository.upsertAccount(testAccount()); + } + } + + Future restart() async { + await repository.dispose(); + await open(); + } + + Future seed() async { + remote[1] = { + ...serverNote(1), + 'title': 'A', + 'category': 'Work/Sub', + 'content': 'café 中文 Привет foo_bar C++ alpha beta', + }; + remote[2] = { + ...serverNote(2), + 'title': 'A', + 'category': 'Work/Other', + 'readonly': true, + 'content': 'readonly', + }; + await repository.synchronize(testAccount().id, allowWrites: false); + } +} + +void main() { + late _Fixture f; + setUp(() async { + f = _Fixture(); + f.root = await Directory.systemTemp.createTemp('notes-m2-'); + await f.open(); + }); + tearDown(() async { + f.mediaGate?.complete(); + await f.repository.dispose(); + await f.root.delete(recursive: true); + }); + + test('correction: every occurrence beyond 32 is reachable', () async { + final content = List.filled(40, 'needle').join(' '); + final note = await f.repository.create(testAccount().id, content: content); + final search = NotesSearchController(f.repository.store, testAccount().id); + addTearDown(search.dispose); + await search.search('needle'); + expect(search.state.error, isNull); + expect(search.state.hits.map((h) => (h.localId, h.start, h.end)), [ + for (var i = 0; i < 40; i++) (note.localId, i * 7, i * 7 + 6), + ]); + expect(search.state.truncated, false); + }); + + test( + 'correction: database pages preserve all document identities and offsets', + () async { + final expected = <(String, int?, int?)>{}; + for (var n = 0; n < 10; n++) { + final note = await f.repository.create( + testAccount().id, + title: 'Note $n', + content: List.filled(32, 'needle').join(' '), + ); + expected.addAll([ + for (var i = 0; i < 32; i++) (note.localId, i * 7, i * 7 + 6), + ]); + } + final search = NotesSearchController( + f.repository.store, + testAccount().id, + ); + addTearDown(search.dispose); + var previous = <(String, int?, int?)>{}; + for (final limit in [80, 160, 240, 320, 400]) { + await search.search('needle', limit: limit); + final actual = search.state.hits + .map((h) => (h.localId, h.start, h.end)) + .toSet(); + expect(search.state.error, isNull); + expect(actual.length, limit.clamp(0, 320)); + expect(search.state.hits.length, actual.length); + expect(actual.containsAll(previous), true); + expect(expected.containsAll(actual), true); + expect(search.state.truncated, limit < 320); + previous = actual; + } + expect(previous, expected); + await f.restart(); + final reopened = NotesSearchController( + f.repository.store, + testAccount().id, + ); + addTearDown(reopened.dispose); + await reopened.search('needle', limit: 400); + expect( + reopened.state.hits.map((h) => (h.localId, h.start, h.end)).toSet(), + expected, + ); + }, + ); + + test( + 'correction: a new reviewed import creates a distinct note in its reviewed category', + () async { + final source = await Directory(p.join(f.root.path, 'source')).create(); + await File( + p.join(source.path, 'note.md'), + ).writeAsString('unchanged body'); + var service = NotesTransferService(f.repository); + final first = await service.importReviewed( + await service.review(source.path, testAccount().id), + testAccount().id, + cancellation: NotesTransferCancellation(), + ); + await f.repository.synchronize(testAccount().id); + await f.restart(); + service = NotesTransferService(f.repository); + final review = await service.review(source.path, testAccount().id); + expect(review.items.single.collision, true); + review.items.single.category = 'Reviewed/子'; + final second = await service.importReviewed( + review, + testAccount().id, + cancellation: NotesTransferCancellation(), + ); + expect(second.single.alreadyImported, false); + expect(second.single.noteId, isNot(first.single.noteId)); + expect( + f.repository.noteById(second.single.noteId!)!.category, + 'Reviewed/子', + ); + await f.repository.synchronize(testAccount().id); + expect(f.repository.notes.length, 2); + expect( + f.repository.notes.every((n) => n.syncState == NoteSyncState.synced), + true, + ); + expect(f.remote.values.map((n) => n['category']).toSet(), { + '', + 'Reviewed/子', + }); + }, + ); + + test( + 'correction: a fresh import honors repaired and changed media with unchanged Markdown', + () async { + final source = await Directory(p.join(f.root.path, 'source')).create(); + await File( + p.join(source.path, 'note.md'), + ).writeAsString('![image](photo.png)'); + final service = NotesTransferService(f.repository); + final missing = await service.review(source.path, testAccount().id); + expect(missing.items.single.issues, isNotEmpty); + final first = await service.importReviewed( + missing, + testAccount().id, + cancellation: NotesTransferCancellation(), + ); + await File(p.join(source.path, 'photo.png')).writeAsBytes([4, 5, 6]); + for (final bytes in [ + [4, 5, 6], + [7, 8, 9], + ]) { + await File(p.join(source.path, 'photo.png')).writeAsBytes(bytes); + final review = await service.review(source.path, testAccount().id); + expect(review.items.single.issues, isEmpty); + final imported = await service.importReviewed( + review, + testAccount().id, + cancellation: NotesTransferCancellation(), + ); + expect(imported.single.noteId, isNot(first.single.noteId)); + final attachments = await f.repository.store.attachments( + imported.single.noteId, + ); + expect(attachments, hasLength(1)); + expect( + await f.repository.store.attachmentBytes(attachments.single.id), + bytes, + ); + } + await f.restart(); + expect(f.repository.notes, hasLength(3)); + }, + ); + + for (final prefix in ['./', '../']) { + test( + 'correction: Markdown and HTML local media resolve safely with $prefix', + () async { + final source = await Directory(p.join(f.root.path, 'source')).create(); + await Directory(p.join(source.path, 'images')).create(); + await Directory(p.join(source.path, 'chapters')).create(); + final document = prefix == './' ? 'note.md' : 'chapters/note.md'; + final reference = '${prefix}images/caf%C3%A9%2520.png'; + await File( + p.join(source.path, 'images/café%20.png'), + ).writeAsBytes([4, 3, 2]); + await File( + p.join(source.path, document), + ).writeAsString('![image]($reference)\n\n\n'); + final service = NotesTransferService(f.repository); + final review = await service.review(source.path, testAccount().id); + expect(review.items.single.issues, isEmpty); + expect(review.items.single.media.keys, [reference]); + final result = await service.importReviewed( + review, + testAccount().id, + cancellation: NotesTransferCancellation(), + ); + final note = f.repository.noteById(result.single.noteId!)!; + final attachment = (await f.repository.store.attachments( + note.localId, + )).single; + expect( + note.content, + '![image](${attachment.reference})\n\n\n', + ); + expect(await f.repository.store.attachmentBytes(attachment.id), [ + 4, + 3, + 2, + ]); + await f.restart(); + expect( + await f.repository.resolveCachedMedia( + testAccount().id, + note.localId, + attachment.reference, + ), + isNotNull, + ); + expect(canonicalAttachmentReference(reference), isNull); + await f.repository.synchronize(testAccount().id); + expect( + f.repository.noteById(note.localId)!.syncState, + NoteSyncState.synced, + ); + }, + ); + } + + test( + 'correction: outside-root local media is rejected without reading its bytes', + () async { + final source = await Directory(p.join(f.root.path, 'source')).create(); + await File(p.join(f.root.path, 'outside.png')).writeAsBytes([9, 9, 9]); + await File(p.join(source.path, 'note.md')).writeAsString( + '![image](../outside.png)\n\n\n', + ); + final review = await NotesTransferService( + f.repository, + ).review(source.path, testAccount().id); + expect(review.items.single.media, isEmpty); + expect(review.items.single.issues, isNotEmpty); + }, + ); + + test( + 'correction: deleted recovery bytes are unavailable to live offline media', + () async { + f.remote[1] = { + ...serverNote(1), + 'content': '![image](.attachments.1/café%2520.png)', + }; + await f.repository.synchronize(testAccount().id, allowWrites: false); + final note = f.repository.notes.single; + const reference = '.attachments.1/café%2520.png'; + await f.repository.resolveMedia( + testAccount().id, + note.localId, + reference, + ); + final original = (await f.repository.store.attachments( + note.localId, + )).single; + await f.repository.deleteAttachment( + note.localId, + reference, + retainForHistory: true, + ); + expect(await f.repository.store.attachmentBytes(original.id), [1, 2, 3]); + expect( + await f.repository.resolveCachedMedia( + testAccount().id, + note.localId, + reference, + ), + isNull, + ); + final availability = await f.repository.attachmentAvailability( + note.localId, + ); + expect(availability.required.map(canonicalAttachmentReference), [ + canonicalAttachmentReference(reference), + ]); + expect(availability.available, isEmpty); + await f.restart(); + final offline = NotesOfflineController(f.repository, testAccount().id); + addTearDown(offline.dispose); + await offline.initialize(); + await offline.setRequirement('note', note.localId); + expect( + (await offline.inspect(f.repository.noteById(note.localId)!)).available, + false, + ); + expect( + (await f.repository.store.attachments(note.localId)).single.state, + 'deleted', + ); + expect(await f.repository.store.attachmentBytes(original.id), [1, 2, 3]); + final replacement = await f.repository.addAttachment( + note.localId, + filename: 'café%20.png', + bytes: Uint8List.fromList([7, 8, 9]), + ); + await f.repository.save( + note.localId, + content: '![image](${replacement.reference})', + ); + await f.repository.synchronize(testAccount().id); + expect( + (await f.repository.store.attachments( + note.localId, + )).where((a) => a.id == replacement.id).single.state, + 'uploaded', + ); + expect( + (await offline.inspect(f.repository.noteById(note.localId)!)).available, + true, + ); + expect( + await f.repository.resolveCachedMedia( + testAccount().id, + note.localId, + reference, + ), + isNotNull, + ); + expect(await f.repository.store.attachmentBytes(original.id), [1, 2, 3]); + }, + ); + + test( + 'correction: worker continuation spans occurrences, candidates and Unicode ranges', + () async { + final expected = <(String, int?, int?)>{}; + for (var n = 0; n < 40; n++) { + final source = List.filled( + n == 0 ? 403 : 2, + 'cafe\u0301 café', + ).join(' '); + final note = await f.repository.create( + testAccount().id, + content: source, + ); + expected.addAll( + RegExp( + 'cafe\u0301|café', + ).allMatches(source).map((m) => (note.localId, m.start, m.end)), + ); + } + final actual = <(String, int?, int?)>[]; + var after = 0; + Map? continuation; + var more = true, calls = 0; + while (more) { + expect(++calls, lessThan(100)); + final page = await f.repository.store.searchChunk( + testAccount().id, + NotesSearchQuery('café'), + after: after, + continuation: continuation, + limit: 200, + ); + final hits = (page['hits'] as List) + .map( + (h) => + NotesSearchHit.fromJson(Map.from(h as Map)), + ) + .toList(); + expect(hits.length, lessThanOrEqualTo(200)); + actual.addAll(hits.map((h) => (h.localId, h.start, h.end))); + after = page['after'] as int; + continuation = page['continuation'] == null + ? null + : Map.from(page['continuation'] as Map); + more = page['more'] as bool; + } + expect(actual.length, expected.length); + expect(actual.toSet(), expected); + final search = NotesSearchController( + f.repository.store, + testAccount().id, + ); + addTearDown(search.dispose); + await search.search('café', limit: 1000); + expect( + search.state.hits.map((h) => (h.localId, h.start, h.end)).toSet(), + expected, + ); + expect(search.state.truncated, false); + }, + ); + + test( + 'correction: partial-document continuation rejects changed revisions', + () async { + final note = await f.repository.create( + testAccount().id, + content: List.filled(40, 'needle').join(' '), + ); + final first = await f.repository.store.searchChunk( + testAccount().id, + NotesSearchQuery('needle'), + limit: 10, + ); + expect(first['more'], true); + expect(first['continuation'], isNotNull); + await f.repository.save(note.localId, content: 'changed needle'); + await expectLater( + f.repository.store.searchChunk( + testAccount().id, + NotesSearchQuery('needle'), + after: first['after'] as int, + continuation: Map.from(first['continuation'] as Map), + ), + throwsStateError, + ); + final search = NotesSearchController( + f.repository.store, + testAccount().id, + ); + addTearDown(search.dispose); + await search.search('needle'); + expect(search.state.hits.map((h) => (h.start, h.end)), [(8, 14)]); + }, + ); + + test( + 'correction: interrupted import is reconstructed from its durable operation after restart', + () async { + final source = await Directory(p.join(f.root.path, 'resume')).create(); + for (var n = 0; n < 3; n++) { + await File( + p.join(source.path, '$n.md'), + ).writeAsString('resume body $n'); + } + var service = NotesTransferService(f.repository); + final original = await service.review(source.path, testAccount().id); + for (final item in original.items) { + item.category = 'Reviewed/Resume'; + } + final cancel = NotesTransferCancellation(); + final first = await service.importReviewed( + original, + testAccount().id, + cancellation: cancel, + onProgress: (_, _) => cancel.cancel(), + ); + expect(first, hasLength(1)); + final pending = await f.repository.store.importOperations( + testAccount().id, + ); + expect(pending.single['id'], original.operationId); + await f.restart(); + service = NotesTransferService(f.repository); + final resumed = await service.resumeImport( + original.operationId, + testAccount().id, + ); + expect(resumed.items.first.importedNoteId, first.single.noteId); + expect(resumed.items.every((i) => i.category == 'Reviewed/Resume'), true); + final second = await service.importReviewed( + resumed, + testAccount().id, + cancellation: NotesTransferCancellation(), + ); + expect(second.map((o) => o.alreadyImported), [true, false, false]); + expect(second.first.noteId, first.single.noteId); + expect(f.repository.notes, hasLength(3)); + expect( + await f.repository.store.importOperations(testAccount().id), + isEmpty, + ); + await f.repository.synchronize(testAccount().id); + final writes = f.writes; + await f.restart(); + final completed = await NotesTransferService( + f.repository, + ).resumeImport(original.operationId, testAccount().id); + final replayed = await NotesTransferService(f.repository).importReviewed( + completed, + testAccount().id, + cancellation: NotesTransferCancellation(), + ); + expect(replayed.every((o) => o.alreadyImported), true); + await f.repository.synchronize(testAccount().id); + expect(f.writes, writes); + final fresh = await NotesTransferService( + f.repository, + ).review(source.path, testAccount().id); + expect(fresh.operationId, isNot(original.operationId)); + expect(fresh.items.every((i) => !i.alreadyImported), true); + await f.repository.removeAccount(testAccount().id); + expect( + await f.repository.store.importOperation( + testAccount().id, + original.operationId, + ), + isNull, + ); + await expectLater( + NotesTransferService( + f.repository, + ).resumeImport(original.operationId, testAccount().id), + throwsStateError, + ); + }, + ); + + test( + 'correction: Recovery search is account-scoped and excluded from live indexing', + () async { + final deleted = await f.repository.create( + testAccount().id, + title: 'Retained 子', + category: 'Gone/Sub', + content: 'forty retained needles', + ); + await f.repository.delete(deleted.localId); + final pending = await f.repository.create( + testAccount().id, + title: 'Deletion conflict', + content: 'retained needles', + ); + await f.repository.store.saveNote( + pending.copyWith(syncState: NoteSyncState.deletedRemotely), + ); + final other = NextcloudAccount( + id: 'aaf20ae4-efbb-43a5-8f57-88bb0a6b9132', + server: testAccount().server, + loginName: 'Other', + appVersion: '6.1.0', + ); + await f.repository.upsertAccount(other); + final foreign = await f.repository.create( + other.id, + content: 'retained needles', + ); + await f.repository.delete(foreign.localId); + await f.restart(); + final recovery = NotesSearchController( + f.repository.store, + testAccount().id, + recovery: true, + ); + final live = NotesSearchController(f.repository.store, testAccount().id); + addTearDown(recovery.dispose); + addTearDown(live.dispose); + await recovery.search('"retained needles" category:Gone'); + expect(recovery.state.hits.single.localId, deleted.localId); + expect(recovery.state.hits.single.start, 6); + await live.search('retained'); + expect(live.state.hits.map((h) => h.localId), [pending.localId]); + final restored = await f.repository.recoverAsNew(deleted.localId); + expect(restored.localId, isNot(deleted.localId)); + await f.repository.synchronize(testAccount().id); + expect( + f.repository.noteById(restored.localId)!.syncState, + NoteSyncState.synced, + ); + await recovery.search('title:"Retained 子"'); + expect(recovery.state.hits.single.localId, deleted.localId); + await f.repository.removeAccount(testAccount().id); + await recovery.search('retained'); + expect(recovery.state.hits, isEmpty); + }, + ); + + test( + 'correction: resumed operation preserves uncertain creation and upload evidence', + () async { + final source = await Directory(p.join(f.root.path, 'uncertain')).create(); + await File( + p.join(source.path, '0.md'), + ).writeAsString('![image](photo.png)'); + await File(p.join(source.path, '1.md')).writeAsString('other'); + await File(p.join(source.path, 'photo.png')).writeAsBytes([1, 2, 3]); + var service = NotesTransferService(f.repository); + final review = await service.review(source.path, testAccount().id); + final cancel = NotesTransferCancellation(); + final first = await service.importReviewed( + review, + testAccount().id, + cancellation: cancel, + onProgress: (_, _) => cancel.cancel(), + ); + final id = first.single.noteId!; + f.loseNextCreate = true; + await f.repository.synchronize(testAccount().id); + expect( + f.repository.noteById(id)!.syncState, + NoteSyncState.creationUncertain, + ); + final evidence = f.repository.noteById(id)!.creationAttempt!; + await f.restart(); + service = NotesTransferService(f.repository); + final resumed = await service.resumeImport( + review.operationId, + testAccount().id, + ); + final second = await service.importReviewed( + resumed, + testAccount().id, + cancellation: NotesTransferCancellation(), + ); + expect(second.first.noteId, id); + expect(second.first.alreadyImported, true); + expect( + f.repository.noteById(id)!.creationAttempt!.toJson(), + evidence.toJson(), + ); + await f.repository.synchronize(testAccount().id); + expect(f.remote.length, 2); + expect( + f.repository.noteById(id)!.syncState, + NoteSyncState.creationUncertain, + ); + final candidate = NoteState.fromJson(f.remote.values.first); + await f.repository.resolveConflict( + id, + NoteConflictResolution.useServerNote, + creationCandidateServerId: candidate.id, + creationReview: f.repository.creationReview(id, candidate), + ); + f.loseNextUpload = true; + await f.repository.synchronize(testAccount().id); + final staged = (await f.repository.store.attachments(id)).single; + expect(staged.state, 'uncertain'); + expect(await f.repository.store.attachmentBytes(staged.id), [1, 2, 3]); + final uploads = f.uploads; + await f.restart(); + final complete = await NotesTransferService( + f.repository, + ).resumeImport(review.operationId, testAccount().id); + final replay = await NotesTransferService(f.repository).importReviewed( + complete, + testAccount().id, + cancellation: NotesTransferCancellation(), + ); + expect(replay.every((o) => o.alreadyImported), true); + await f.repository.synchronize(testAccount().id); + expect(f.uploads, uploads); + expect( + (await f.repository.store.attachments(id)).single.state, + 'uncertain', + ); + expect(f.repository.notes, hasLength(2)); + }, + ); + + test( + 'correction: schema 5 migration preserves existing item ledger and all durable data', + () async { + final note = await f.repository.importLocal( + accountId: testAccount().id, + sourceKey: 'legacy-import-key', + title: 'legacy', + category: 'Legacy/Sub', + content: 'body', + media: { + 'photo.png': ( + filename: 'photo.png', + bytes: Uint8List.fromList([8, 7, 6]), + ), + }, + ); + await f.repository.setOfflineRequirement( + testAccount().id, + 'category', + 'Legacy', + ); + final attachment = (await f.repository.store.attachments( + note.localId, + )).single; + await f.repository.dispose(); + var db = sqlite3.open(p.join(f.root.path, 'notes.db')); + db.execute('DROP TABLE import_operations'); + db.execute('PRAGMA user_version=5'); + final before = { + for (final table in [ + 'accounts', + 'notes', + 'outbox', + 'attachments', + 'import_items', + 'offline_requirements', + ]) + table: db + .select('SELECT * FROM $table') + .map((r) => Map.from(r)) + .toList(), + }; + db.close(); + await f.open(); + expect( + await f.repository.store.importedNote( + testAccount().id, + 'legacy-import-key', + ), + note.localId, + ); + expect(await f.repository.store.attachmentBytes(attachment.id), [ + 8, + 7, + 6, + ]); + db = sqlite3.open(f.repository.store.path); + expect(db.select('PRAGMA user_version').single.values.single, 6); + for (final entry in before.entries) { + expect( + db + .select('SELECT * FROM ${entry.key}') + .map((r) => Map.from(r)) + .toList(), + entry.value, + ); + } + db.close(); + }, + ); + + test( + 'correction: dirty overlays expose every occurrence without saving', + () async { + final note = await f.repository.create( + testAccount().id, + content: 'saved body', + ); + final search = NotesSearchController( + f.repository.store, + testAccount().id, + ); + addTearDown(search.dispose); + final source = List.filled(103, 'needle').join(' '); + for (final limit in [80, 160]) { + await search.search( + 'needle', + limit: limit, + overlays: [ + NotesSearchOverlay(note.localId, 9, note.title, '', source), + ], + ); + expect(search.state.hits.map((h) => (h.localId, h.start, h.end)), [ + for (var i = 0; i < limit.clamp(0, 103); i++) + (note.localId, i * 7, i * 7 + 6), + ]); + expect(search.state.truncated, limit < 103); + } + expect((await f.repository.store.notes()).single.content, 'saved body'); + }, + ); + + test( + 'correction: overlapping terms continue at the same start without duplication', + () async { + final note = await f.repository.create( + testAccount().id, + content: List.filled(40, 'foobar').join(' '), + ); + final query = NotesSearchQuery('foo foobar'); + final actual = <(int?, int?)>[]; + var after = 0; + Map? continuation; + for (var i = 0; i < 80; i++) { + final page = await f.repository.store.searchChunk( + testAccount().id, + query, + after: after, + continuation: continuation, + limit: 1, + ); + final hit = NotesSearchHit.fromJson( + Map.from((page['hits'] as List).single as Map), + ); + expect(hit.localId, note.localId); + actual.add((hit.start, hit.end)); + expect(page['more'], i < 79); + after = page['after'] as int; + continuation = page['continuation'] == null + ? null + : Map.from(page['continuation'] as Map); + } + expect(actual, [ + for (var i = 0; i < 40; i++) ...[ + (i * 7, i * 7 + 3), + (i * 7, i * 7 + 6), + ], + ]); + }, + ); + + test( + 'correction: navigation relocates late occurrences after a dirty edit', + () async { + final source = 'x ${List.filled(400, 'needle').join(' ')}'; + final hit = await relocateNotesSearchHit( + NotesSearchQuery('needle'), + NotesSearchOverlay('identity', 11, 'Title', '', source), + 350 * 7, + ); + expect(hit!.start, 350 * 7 + 2); + expect(hit.end, 350 * 7 + 8); + expect(hit.revision, 11); + expect(hit.digest, notesSearchDigest(source)); + }, + ); + + test( + 'navigation counts share inclusion, descendants, recovery and stable selection', + () async { + await f.seed(); + final draft = await f.repository.create( + testAccount().id, + title: 'Uncategorized', + ); + final deleted = await f.repository.create( + testAccount().id, + title: 'deleted', + ); + await f.repository.delete(deleted.localId); + final pendingDeleted = await f.repository.create( + testAccount().id, + title: 'deletion conflict', + ); + await f.repository.store.saveNote( + pendingDeleted.copyWith(syncState: NoteSyncState.deletedRemotely), + ); + await f.restart(); + final nav = NotesNavigationController() + ..update(f.repository.notes, testAccount().id); + expect(nav.counts[NotesDestination.all], 4); + expect(nav.counts[NotesDestination.recovery], 1); + expect(nav.categoryCounts, {'Work': 2, 'Work/Sub': 1, 'Work/Other': 1}); + nav.destination = NotesDestination.category; + nav.category = 'Work'; + nav.update(f.repository.notes, testAccount().id); + final ids = nav.visible.map((n) => n.localId).toList(); + nav.select(ids.first); + nav.select(ids.last, range: true); + expect(nav.selected, ids.toSet()); + nav.sort = NotesSort.titleDescending; + nav.update(f.repository.notes, testAccount().id); + expect(nav.selected, ids.toSet()); + expect(nav.contextTargets(draft.localId), [draft.localId]); + nav.destination = NotesDestination.uncategorized; + nav.update(f.repository.notes, testAccount().id); + expect(nav.selected, isEmpty); + nav.selectAll(); + expect(nav.selected.length, 2); + }, + ); + + test( + 'mixed batch permissions, unrelated edits and same-field conflict survive restart', + () async { + await f.seed(); + final a = f.repository.notes.firstWhere((n) => n.serverId == 1); + final b = f.repository.notes.firstWhere((n) => n.serverId == 2); + final reviewed = [ + f.repository.metadataSnapshot(a.localId), + f.repository.metadataSnapshot(b.localId), + ]; + await f.repository.save(a.localId, content: 'pending body'); + await f.repository.patchMetadata( + f.repository.metadataSnapshot(a.localId), + title: 'concurrent title', + ); + final results = await f.repository.patchMetadataBatch( + reviewed, + category: 'Moved/Sub', + ); + expect(results.map((r) => r.status), [ + NotesBatchStatus.changed, + NotesBatchStatus.skipped, + ]); + expect(f.repository.noteById(a.localId)!.title, 'concurrent title'); + expect(f.repository.noteById(a.localId)!.content, 'pending body'); + await f.repository.patchMetadata( + f.repository.metadataSnapshot(a.localId), + category: 'Other', + ); + final conflicts = await f.repository.patchMetadataBatch( + reviewed, + category: 'Reviewed', + ); + expect(conflicts.first.status, NotesBatchStatus.conflicted); + await f.restart(); + expect(f.repository.noteById(a.localId)!.metadataConflict, isNotNull); + final favorites = await f.repository.patchMetadataBatch([ + f.repository.metadataSnapshot(b.localId), + ], favorite: true); + expect(favorites.single.status, NotesBatchStatus.changed); + await f.repository.synchronize(testAccount().id); + expect(f.remote[2]!['favorite'], true); + expect(f.remote[2]!['content'], 'readonly'); + expect(f.repository.noteById(a.localId)!.metadataConflict, isNotNull); + }, + ); + + for (final query in [ + '"alpha beta"', + 'alpha beta', + 'title:A category:Work', + '中文', + 'é', + 'Привет', + 'C++', + 'foo_bar', + 'cafe\u0301', + ]) { + test( + 'indexed literal and multilingual query $query matches unopened durable notes', + () async { + await f.seed(); + final search = NotesSearchController( + f.repository.store, + testAccount().id, + ); + addTearDown(search.dispose); + await search.search(query); + expect(search.state.error, isNull); + expect(search.state.hits, isNotEmpty); + for (final hit in search.state.hits.where((h) => h.start != null)) { + final source = f.repository.noteById(hit.localId)!.content; + expect(hit.start, greaterThanOrEqualTo(0)); + expect(hit.end, lessThanOrEqualTo(source.length)); + expect(source.substring(hit.start!, hit.end!), isNotEmpty); + } + }, + ); + } + test( + 'FTS operators and punctuation stay literal; Unicode whole words and short terms', + () async { + final note = await f.repository.create( + testAccount().id, + content: + 'AND OR foo_bar foobar foo βήτα βήταx cafe\u0301 café 100% C++', + ); + final search = NotesSearchController( + f.repository.store, + testAccount().id, + ); + addTearDown(search.dispose); + await search.search('AND'); + expect(search.state.hits.length, 1); + await search.search('foo', wholeWord: true); + expect(search.state.hits.length, 1); + expect( + note.content.substring( + search.state.hits.single.start!, + search.state.hits.single.end!, + ), + 'foo', + ); + await search.search('βήτα', wholeWord: true); + expect(search.state.hits.length, 1); + await search.search('100%'); + expect(search.state.hits.length, 1); + await search.search('"OR foo_bar"'); + expect(search.state.hits.length, 1); + await search.search('café'); + expect(search.state.hits.length, 2); + expect( + search.state.hits.map((h) => note.content.substring(h.start!, h.end!)), + ['cafe\u0301', 'café'], + ); + await search.search('foo*'); + expect(search.state.hits, isEmpty); + }, + ); + test( + 'pending and dirty overlay, cancellation, pagination and changed-result relocation', + () async { + final note = await f.repository.create( + testAccount().id, + content: 'saved needle', + ); + final search = NotesSearchController( + f.repository.store, + testAccount().id, + ); + addTearDown(search.dispose); + await search.search( + 'needle', + overlays: [ + NotesSearchOverlay( + note.localId, + 10, + note.title, + note.category, + 'dirty needle needle', + ), + ], + limit: 1, + ); + expect(search.state.truncated, true); + expect(search.state.hits.single.revision, 10); + expect(f.repository.noteById(note.localId)!.content, 'saved needle'); + final stale = search.state.hits.single; + final running = search.search('saved'); + await search.search('missing'); + await running; + expect(search.state.hits, isEmpty); + final changed = matchNotesDocument( + query: NotesSearchQuery('needle'), + localId: note.localId, + revision: 11, + title: note.title, + category: '', + source: 'prefix dirty needle', + ); + expect(changed.single.start, isNot(stale.start)); + expect(notesSearchDigest('prefix dirty needle'), isNot(stale.digest)); + }, + ); + test( + 'restart, derived index loss/rebuild, deletion and account removal erase search content', + () async { + final note = await f.repository.create( + testAccount().id, + content: 'findme', + ); + await f.restart(); + await f.repository.store.rebuildIndex(); + final search = NotesSearchController( + f.repository.store, + testAccount().id, + ); + await search.search('findme'); + expect(search.state.hits.single.localId, note.localId); + await f.repository.delete(note.localId); + await search.search('findme'); + expect(search.state.hits, isEmpty); + await f.repository.removeAccount(testAccount().id); + final db = sqlite3.open(f.repository.store.path); + expect(db.select('SELECT count(*) AS n FROM note_search').single['n'], 0); + expect( + db.select('SELECT count(*) AS n FROM note_search_map').single['n'], + 0, + ); + db.close(); + await search.dispose(); + }, + ); + + test( + 'offline category descendants, independent retention, new refs and restart', + () async { + f.remote[1] = { + ...serverNote(1), + 'category': 'Work/Sub', + 'content': '![image](.attachments.1/a.png)', + }; + await f.repository.synchronize(testAccount().id, allowWrites: false); + final note = f.repository.notes.single; + var offline = NotesOfflineController(f.repository, testAccount().id); + await offline.initialize(); + await offline.setRequirement('category', 'Work'); + await offline.reconcile(); + expect((await offline.inspect(note)).available, true); + expect(f.downloads, 1); + await offline.setRequirement('note', note.localId); + await offline.setRequirement('category', 'Work', required: false); + expect(offline.retained(note), true); + await f.repository.save( + note.localId, + content: '![image](.attachments.1/a.png) ![new](.attachments.1/b.png)', + ); + expect( + (await offline.inspect(f.repository.noteById(note.localId)!)).available, + false, + ); + await offline.reconcile(); + expect( + (await offline.inspect(f.repository.noteById(note.localId)!)).available, + true, + ); + await offline.dispose(); + await f.restart(); + f.offline = true; + offline = NotesOfflineController(f.repository, testAccount().id); + await offline.initialize(); + expect(offline.individuallyRetained(note.localId), true); + expect( + (await offline.inspect(f.repository.noteById(note.localId)!)).available, + true, + ); + expect( + await f.repository.resolveCachedMedia( + testAccount().id, + note.localId, + '.attachments.1/b.png', + ), + isNotNull, + ); + await offline.dispose(); + }, + ); + test( + 'offline failure needs retry; paused category does not cancel independent requirement', + () async { + f.remote[1] = { + ...serverNote(1), + 'category': 'Work/Sub', + 'content': '![image](.attachments.1/a.png)', + }; + await f.repository.synchronize(testAccount().id, allowWrites: false); + final note = f.repository.notes.single; + final offline = NotesOfflineController(f.repository, testAccount().id); + await offline.initialize(); + f.failMedia = true; + await offline.setRequirement('category', 'Work'); + await offline.reconcile(); + expect((await offline.inspect(note)).available, false); + final downloads = f.downloads; + await offline.reconcile(); + expect(f.downloads, downloads); + f.failMedia = false; + await offline.retry(); + expect((await offline.inspect(note)).available, true); + await offline.setRequirement('note', note.localId); + await offline.setRequirement('category', 'Work', paused: true); + expect((await offline.inspect(note)).paused, false); + await offline.dispose(); + }, + ); + test( + 'account removal fences a late offline download and requirements', + () async { + f.remote[1] = { + ...serverNote(1), + 'category': 'Work/Sub', + 'content': '![image](.attachments.1/a.png)', + }; + await f.repository.synchronize(testAccount().id, allowWrites: false); + final offline = NotesOfflineController(f.repository, testAccount().id); + await offline.initialize(); + f.mediaGate = Completer(); + final download = offline.setRequirement('category', 'Work'); + await f.mediaStarted.future; + await f.repository.removeAccount(testAccount().id); + f.mediaGate!.complete(); + f.mediaGate = null; + await download; + expect(await f.repository.store.attachments(), isEmpty); + expect( + await f.repository.store.offlineRequirements(testAccount().id), + isEmpty, + ); + await offline.dispose(); + }, + ); + test( + 'pending upload bytes are available offline before acknowledgement', + () async { + final note = await f.repository.create(testAccount().id); + final attachment = await f.repository.addAttachment( + note.localId, + filename: 'é%.png', + bytes: Uint8List.fromList([1, 2]), + ); + final saved = await f.repository.save( + note.localId, + content: '![local](${attachment.reference})', + ); + final offline = NotesOfflineController(f.repository, testAccount().id); + expect((await offline.inspect(saved)).available, true); + expect(saved.hasPendingChanges, true); + await offline.setRequirement('note', note.localId, required: false); + expect(await f.repository.store.attachmentBytes(attachment.id), [1, 2]); + await offline.dispose(); + }, + ); + test( + 'deleted draft and retained media recover as new and synchronize', + () async { + final note = await f.repository.create( + testAccount().id, + title: 'retained', + ); + final a = await f.repository.addAttachment( + note.localId, + filename: 'café%.png', + bytes: Uint8List.fromList([1, 2, 3]), + ); + await f.repository.save( + note.localId, + content: '![image](${a.reference})', + ); + await f.repository.delete(note.localId); + await f.restart(); + final recovered = await f.repository.recoverAsNew(note.localId); + expect(recovered.localId, isNot(note.localId)); + expect(recovered.serverId, isNull); + expect( + (await f.repository.store.attachments(recovered.localId)).length, + 1, + ); + await f.repository.synchronize(testAccount().id); + expect( + f.repository.noteById(recovered.localId)!.syncState, + NoteSyncState.synced, + ); + expect( + f.repository.noteById(note.localId)!.syncState, + NoteSyncState.deletedRemotely, + ); + }, + ); + + test( + 'portable nested round trip duplicates favorites Unicode and Markdown/HTML media', + () async { + final note = await f.repository.create( + testAccount().id, + title: '同じ:/CON?', + category: 'Work/子', + content: 'body', + ); + final a = await f.repository.addAttachment( + note.localId, + filename: 'café%20.png', + bytes: Uint8List.fromList([1, 2, 3]), + ); + await f.repository.save( + note.localId, + content: + '![one](${a.reference})\n\n`![literal](${a.reference})`\n[doc](other.md)', + ); + await f.repository.patchMetadata( + f.repository.metadataSnapshot(note.localId), + favorite: true, + ); + await f.repository.create( + testAccount().id, + title: note.title, + category: note.category, + content: 'duplicate', + ); + final service = NotesTransferService(f.repository); + final exported = await service.exportSnapshot( + notes: f.repository.notes, + destination: f.root.path, + cancellation: NotesTransferCancellation(), + ); + expect(exported.complete, true); + final review = await service.review(exported.path, testAccount().id); + expect(review.items.length, 2); + expect(review.items.every((i) => i.collision), true); + expect( + review.items.where((i) => i.media.isNotEmpty).single.media.length, + 1, + ); + final imported = await service.importReviewed( + review, + testAccount().id, + cancellation: NotesTransferCancellation(), + ); + expect(imported.every((i) => i.noteId != null), true); + final copies = imported + .map((i) => f.repository.noteById(i.noteId!)!) + .toList(); + expect( + copies.every((n) => n.category == 'Work/子' && n.title == note.title), + true, + ); + expect(copies.where((n) => n.favorite).length, 1); + expect( + copies.where((n) => n.favorite).single.content, + contains('[doc](other.md)'), + ); + expect( + copies.where((n) => n.favorite).single.content, + contains('`![literal](${a.reference})`'), + ); + await f.restart(); + final repeated = await NotesTransferService(f.repository).importReviewed( + review, + testAccount().id, + cancellation: NotesTransferCancellation(), + ); + expect(repeated.every((o) => o.alreadyImported), true); + expect(f.repository.notes.length, 4); + }, + ); + test( + 'missing media explicit, no external fetching; cancellation leaves no published folder', + () async { + await f.seed(); + final note = await f.repository.create( + testAccount().id, + content: + '![missing](missing.png) ![external](https://example.org/a.png)', + ); + final service = NotesTransferService(f.repository); + final result = await service.exportSnapshot( + notes: [note], + destination: f.root.path, + cancellation: NotesTransferCancellation(), + ); + expect(result.complete, false); + expect(result.omissions.length, 2); + expect(f.downloads, 0); + final cancel = NotesTransferCancellation()..cancel(); + await expectLater( + service.exportSnapshot( + notes: [note], + destination: f.root.path, + cancellation: cancel, + ), + throwsA(isA()), + ); + expect( + await f.root + .list() + .where((e) => p.basename(e.path).startsWith('.busymark-export-')) + .length, + 0, + ); + }, + ); + test( + 'manifest versions, traversal, symlinks and changed files are rejected', + () async { + final folder = await Directory(p.join(f.root.path, 'source')).create(); + final outside = File(p.join(f.root.path, 'outside.png')); + await outside.writeAsBytes([1]); + final file = File(p.join(folder.path, 'a.md')); + await file.writeAsString('![image](../outside.png)'); + final service = NotesTransferService(f.repository); + var review = await service.review(folder.path, testAccount().id); + expect(review.items.single.issues, isNotEmpty); + expect(review.items.single.media, isEmpty); + await file.writeAsString('changed'); + final changed = await service.importReviewed( + review, + testAccount().id, + cancellation: NotesTransferCancellation(), + ); + expect(changed.single.error, isNotNull); + expect(f.repository.notes, isEmpty); + await Link(p.join(folder.path, 'link.md')).create(file.path); + review = await service.review(folder.path, testAccount().id); + expect(review.issues, isNotEmpty); + await File( + p.join(folder.path, NotesTransferService.manifestName), + ).writeAsString( + jsonEncode({ + 'format': 'busymark-notes', + 'version': 99, + 'documents': [], + }), + ); + await expectLater( + service.review(folder.path, testAccount().id), + throwsFormatException, + ); + await File( + p.join(folder.path, NotesTransferService.manifestName), + ).writeAsString( + jsonEncode({ + 'format': 'busymark-notes', + 'version': 1, + 'documents': [ + { + 'file': '../outside.png', + 'title': 'bad', + 'category': '', + 'favorite': false, + }, + ], + }), + ); + await expectLater( + service.review(folder.path, testAccount().id), + throwsFormatException, + ); + }, + ); + test( + 'partial cancelled import resumes associations without duplicate local notes', + () async { + final folder = await Directory(p.join(f.root.path, 'source')).create(); + for (var i = 0; i < 3; i++) { + await File(p.join(folder.path, '$i.md')).writeAsString('body $i'); + } + final service = NotesTransferService(f.repository); + final review = await service.review(folder.path, testAccount().id); + final cancel = NotesTransferCancellation(); + final first = await service.importReviewed( + review, + testAccount().id, + cancellation: cancel, + onProgress: (a, b) => cancel.cancel(), + ); + expect(first.length, 1); + expect(f.repository.notes.length, 1); + await f.restart(); + final second = await NotesTransferService(f.repository).importReviewed( + review, + testAccount().id, + cancellation: NotesTransferCancellation(), + ); + expect(second.first.alreadyImported, true); + expect(f.repository.notes.length, 3); + await f.repository.synchronize(testAccount().id); + expect( + f.repository.notes.every((n) => n.syncState == NoteSyncState.synced), + true, + ); + }, + ); + test( + 'note cancellation overrides category work durably and retry resumes', + () async { + f.remote[1] = { + ...serverNote(1), + 'category': 'Work/Sub', + 'content': '![image](.attachments.1/a.png)', + }; + await f.repository.synchronize(testAccount().id, allowWrites: false); + final note = f.repository.notes.single; + var offline = NotesOfflineController(f.repository, testAccount().id); + await offline.initialize(); + await offline.cancelNote(note.localId); + await offline.setRequirement('category', 'Work'); + await offline.reconcile(); + expect(f.downloads, 0); + expect((await offline.inspect(note)).paused, true); + await offline.dispose(); + await f.restart(); + offline = NotesOfflineController(f.repository, testAccount().id); + await offline.initialize(); + await offline.reconcile(); + expect(f.downloads, 0); + await offline.retry(note.localId); + expect((await offline.inspect(note)).available, true); + expect(f.downloads, 1); + await offline.dispose(); + }, + ); + test( + 'captured export rejects refreshed media but preserves captured text during later edits', + () async { + f.remote[1] = serverNote(1); + await f.repository.synchronize(testAccount().id, allowWrites: false); + final note = f.repository.notes.single; + final snapshot = await f.repository.captureLocalSnapshot( + testAccount().id, + {note.localId}, + ); + f.remote[1] = {...f.remote[1]!, 'etag': 'media-refreshed'}; + await f.repository.synchronize(testAccount().id, allowWrites: false); + final service = NotesTransferService(f.repository); + await expectLater( + service.exportSnapshot( + notes: snapshot, + destination: f.root.path, + cancellation: NotesTransferCancellation(), + ), + throwsA(isA()), + ); + expect(f.root.listSync().whereType(), isEmpty); + final fresh = await f.repository.captureLocalSnapshot(testAccount().id, { + note.localId, + }); + await f.repository.save(note.localId, content: 'later local edit'); + final exported = await service.exportSnapshot( + notes: fresh, + destination: f.root.path, + cancellation: NotesTransferCancellation(), + ); + final review = await service.review(exported.path, testAccount().id); + expect(review.items.single.content, fresh.single.content); + expect(f.repository.noteById(note.localId)!.content, 'later local edit'); + }, + ); + test( + 'account removal during export prevents publication and cleans staging', + () async { + final note = await f.repository.create(testAccount().id, content: 'body'); + final snapshot = await f.repository.captureLocalSnapshot( + testAccount().id, + {note.localId}, + ); + Future? removal; + await expectLater( + NotesTransferService(f.repository).exportSnapshot( + notes: snapshot, + destination: f.root.path, + cancellation: NotesTransferCancellation(), + onProgress: (_, _) => + removal = f.repository.removeAccount(testAccount().id), + ), + throwsA(isA()), + ); + await removal; + expect(f.root.listSync().whereType(), isEmpty); + expect(f.repository.accounts, isEmpty); + }, + ); + test( + 'export filesystem failure publishes no partial snapshot and keeps unrelated files', + () async { + final note = await f.repository.create( + testAccount().id, + title: 'Disk failure', + content: 'retained', + ); + final destination = File('${f.root.path}/not-a-directory'); + await destination.writeAsString('unrelated'); + await expectLater( + NotesTransferService(f.repository).exportSnapshot( + notes: [note], + destination: destination.path, + cancellation: NotesTransferCancellation(), + ), + throwsA(isA()), + ); + expect(await destination.readAsString(), 'unrelated'); + expect(f.repository.noteById(note.localId)!.content, 'retained'); + }, + ); + test( + 'metadata-only matches highlight original combining characters without a content offset', + () { + final hits = matchNotesDocument( + query: NotesSearchQuery('title:"café"'), + localId: 'id', + revision: 1, + title: 'A cafe\u0301 title', + category: 'Work', + source: 'unrelated', + ); + expect(hits.single.start, isNull); + expect( + hits.single.snippet.substring( + hits.single.snippetMatchStart!, + hits.single.snippetMatchEnd!, + ), + 'cafe\u0301', + ); + expect( + NotesSearchHit.fromJson(hits.single.toJson()).snippetMatchStart, + hits.single.snippetMatchStart, + ); + }, + ); + test( + 'batch same-field conflict blocks publication after an older in-flight PUT response', + () async { + f.remote[1] = serverNote(1); + await f.repository.synchronize(testAccount().id, allowWrites: false); + final id = f.repository.notes.single.localId; + final reviewed = f.repository.metadataSnapshot(id); + await f.repository.save(id, content: 'local content during held PUT'); + f.writeGate = Completer(); + final sending = f.repository.synchronize(testAccount().id); + await f.writeStarted.future; + await f.repository.patchMetadata( + f.repository.metadataSnapshot(id), + category: 'Concurrent', + ); + final result = await f.repository.patchMetadataBatch([ + reviewed, + ], category: 'Reviewed'); + expect(result.single.status, NotesBatchStatus.conflicted); + f.writeGate!.complete(); + await sending; + expect(f.repository.noteById(id)!.metadataConflict, isNotNull); + expect( + f.repository.noteById(id)!.content, + 'local content during held PUT', + ); + expect(f.repository.noteById(id)!.category, 'Reviewed'); + expect(f.remote[1]!['category'], reviewed.note.category); + expect(f.writes, 1); + await f.restart(); + await f.repository.synchronize(testAccount().id); + expect(f.writes, 1); + expect(f.repository.noteById(id)!.metadataConflict, isNotNull); + }, + ); + test( + 'import association, note, index and bytes roll back as one transaction', + () async { + final store = f.repository.store; + final invalid = NextcloudNote( + localId: 'new', + accountId: 'missing', + title: 'bad', + content: 'bad', + ); + await expectLater( + store.createWithAttachments(invalid, [], importKey: 'key'), + throwsStateError, + ); + expect(await store.importedNote(testAccount().id, 'key'), isNull); + expect(await store.notes(), isEmpty); + final db = sqlite3.open(store.path); + expect(db.select('SELECT count(*) AS n FROM note_search').single['n'], 0); + db.close(); + }, + ); +} diff --git a/test/src/nextcloud_notes_ui_test.dart b/test/src/nextcloud_notes_ui_test.dart index 86d902c7..b475a499 100644 --- a/test/src/nextcloud_notes_ui_test.dart +++ b/test/src/nextcloud_notes_ui_test.dart @@ -15,10 +15,12 @@ import 'package:busymark/src/app/system_accent.dart'; import 'package:busymark/src/local_history/local_history_controller.dart'; import 'package:busymark/src/local_history/local_history_store.dart'; import 'package:busymark/src/nextcloud_notes/application/nextcloud_connection.dart'; +import 'package:busymark/src/nextcloud_notes/application/notes_navigation.dart'; import 'package:busymark/src/nextcloud_notes/application/notes_repository.dart'; import 'package:busymark/src/nextcloud_notes/data/notes_store.dart'; import 'package:busymark/src/nextcloud_notes/domain/notes_models.dart'; import 'package:busymark/src/nextcloud_notes/presentation/notes_sidebar.dart'; +import 'package:busymark/src/nextcloud_notes/presentation/notes_workspace_ui.dart'; import 'package:busymark/src/workspace/presentation/settings_screen.dart'; import 'package:busymark/src/workspace/recovery_persistence.dart'; import 'package:busymark/src/workspace/session_persistence.dart'; @@ -31,6 +33,16 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:path/path.dart' as p; +Future settleStorage(WidgetTester tester) async { + for (var i = 0; i < 10; i++) { + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 30)), + ); + await tester.pump(); + } + await tester.pumpAndSettle(); +} + void main() { late Directory root; late NotesRepository repository; @@ -253,6 +265,30 @@ void main() { ), ); } + const metadataBase = NoteState( + id: 12, + etag: 'metadata-base', + content: 'body', + title: 'A', + category: '', + favorite: false, + readonly: false, + modified: 1, + ); + await store.saveNote( + const NextcloudNote( + localId: 'metadata-server', + accountId: accountId, + serverId: 12, + title: 'A', + content: 'body', + etag: 'metadata-base', + base: metadataBase, + revision: 1, + ackRevision: 1, + syncState: NoteSyncState.synced, + ), + ); repository = _CachedNotesRepository(store: store); await repository.initialize(); }); @@ -262,7 +298,7 @@ void main() { }); Future pump(WidgetTester tester) async { - tester.view.physicalSize = const Size(800, 600); + tester.view.physicalSize = const Size(1000, 1000); tester.view.devicePixelRatio = 1; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); @@ -279,14 +315,14 @@ void main() { home: const Scaffold( body: SizedBox( width: 500, - height: 700, + height: 1000, child: NextcloudNotesSidebar(accountId: accountId), ), ), ), ), ); - await tester.pumpAndSettle(); + await settleStorage(tester); } test('Nextcloud settings route selects its dedicated page', () { @@ -296,6 +332,197 @@ void main() { ); }); + testWidgets( + 'correction: Recovery Search finds deleted records rather than live notes', + (tester) async { + await pump(tester); + await tester.tap(find.text('Recovery').first); + await settleStorage(tester); + expect(find.text('Deleted note'), findsOneWidget); + await tester.tap(find.byType(TextField).first); + await settleStorage(tester); + final field = find.descendant( + of: find.byType(NotesSearchDialog), + matching: find.byType(TextField), + ); + await tester.enterText(field, 'title:Deleted'); + await tester.pump(const Duration(milliseconds: 200)); + await settleStorage(tester); + expect( + find.descendant( + of: find.byType(NotesSearchDialog), + matching: find.widgetWithText(ListTile, 'Deleted note'), + ), + findsOneWidget, + ); + expect( + find.descendant( + of: find.byType(NotesSearchDialog), + matching: find.text('Personal note'), + ), + findsNothing, + ); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await settleStorage(tester); + await tester.pumpWidget(const SizedBox.shrink()); + }, + ); + + testWidgets( + 'correction: Show more reaches all 320 indexed occurrences through the production dialog', + (tester) async { + final expected = {}; + await tester.runAsync(() async { + for (var n = 0; n < 10; n++) { + final note = await repository.create( + accountId, + title: 'Page $n', + content: List.filled(32, 'pagingneedle').join(' '), + ); + expected.addAll([ + for (var i = 0; i < 32; i++) + ValueKey<(String, String, int?, int?)>(( + 'notes-search-hit', + note.localId, + i * 13, + i * 13 + 12, + )), + ]); + } + }); + await pump(tester); + await tester.tap(find.byType(TextField).first); + await settleStorage(tester); + await tester.enterText( + find.descendant( + of: find.byType(NotesSearchDialog), + matching: find.byType(TextField), + ), + 'pagingneedle', + ); + await tester.pump(const Duration(milliseconds: 200)); + await settleStorage(tester); + for (final count in [80, 160, 240, 320]) { + final list = tester.widget( + find.byKey(const ValueKey('notes-search-results')), + ); + final delegate = list.childrenDelegate as SliverChildBuilderDelegate; + expect(delegate.estimatedChildCount, count); + final context = tester.element( + find.byKey(const ValueKey('notes-search-results')), + ); + final keys = { + for (var i = 0; i < count; i++) delegate.builder(context, i)!.key!, + }; + expect(keys.length, count); + expect(expected.containsAll(keys), true); + if (count < 320) { + expect(find.text('Show more results'), findsOneWidget); + await tester.tap(find.text('Show more results')); + await settleStorage(tester); + } else { + expect(keys, expected); + expect(find.text('Show more results'), findsNothing); + } + } + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await settleStorage(tester); + await tester.pumpWidget(const SizedBox.shrink()); + }, + ); + + testWidgets( + 'correction: Recovery search activation opens preview and keeps the current document', + (tester) async { + final container = ProviderContainer( + overrides: [ + nextcloudNotesRepositoryProvider.overrideWith( + (ref) async => repository, + ), + localSettingsStoreProvider.overrideWithValue(_TabSettings()), + linuxAccentPlatformProvider.overrideWithValue(false), + localHistoryStoreProvider.overrideWithValue( + MemoryLocalHistoryStore(), + ), + documentSessionStoreProvider.overrideWithValue( + MemoryDocumentSessionStore(), + ), + documentRecoveryStoreProvider.overrideWithValue( + MemoryDocumentRecoveryStore(), + ), + ], + ); + addTearDown(container.dispose); + const personalId = '223e4567-e89b-42d3-a456-426614174000'; + const deletedId = '423e4567-e89b-42d3-a456-426614174000'; + final controller = container.read(workspaceControllerProvider.notifier); + await tester.runAsync(() async { + await controller.openNextcloudWorkspace(accountId); + await controller.openNextcloudNote(personalId); + }); + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const Scaffold( + body: SizedBox( + width: 500, + height: 1000, + child: NextcloudNotesSidebar(accountId: accountId), + ), + ), + ), + ), + ); + await settleStorage(tester); + await tester.tap(find.text('Recovery').first); + await settleStorage(tester); + await tester.tap(find.byType(TextField).first); + await settleStorage(tester); + await tester.enterText( + find.descendant( + of: find.byType(NotesSearchDialog), + matching: find.byType(TextField), + ), + 'title:Deleted', + ); + await tester.pump(const Duration(milliseconds: 200)); + await settleStorage(tester); + expect(find.byTooltip('Source location'), findsNothing); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await settleStorage(tester); + expect(find.byType(NotesSearchDialog), findsNothing); + expect(find.byType(NotesRecoveryDialog), findsOneWidget); + expect( + tester + .widget(find.byType(NotesRecoveryDialog)) + .note + .localId, + deletedId, + ); + expect( + container + .read(workspaceControllerProvider) + .activeBuffer + ?.remoteNote + ?.localId, + personalId, + ); + expect( + container + .read(workspaceControllerProvider) + .documentBuffers + .any((b) => b.remoteNote?.localId == deletedId), + false, + ); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await settleStorage(tester); + await tester.pumpWidget(const SizedBox.shrink()); + }, + ); + testWidgets('remote editor tabs retain note titles through metadata rename', ( tester, ) async { @@ -342,7 +569,7 @@ void main() { child: const BusyMarkApp(), ), ); - await tester.pumpAndSettle(); + await settleStorage(tester); final strip = find.byKey(const ValueKey('editor-tab-strip')); Finder tab(String title) => find.descendant(of: strip, matching: find.text(title)); @@ -358,7 +585,7 @@ void main() { title: 'Renamed personal note', ), ); - await tester.pumpAndSettle(); + await settleStorage(tester); expect(tab('Personal note'), findsNothing); expect(tab('Renamed personal note'), findsOneWidget); expect(tab('Shared note'), findsOneWidget); @@ -410,7 +637,7 @@ void main() { child: const BusyMarkApp(), ), ); - await tester.pumpAndSettle(); + await settleStorage(tester); final target = container .read(workspaceControllerProvider) .documentBuffers @@ -456,7 +683,7 @@ void main() { ); }); await tester.tap(label(), buttons: kSecondaryButton); - await tester.pumpAndSettle(); + await settleStorage(tester); final entries = menu!['entries'] as List; expect((entries[4] as Map)['enabled'], isFalse); expect(clipboardWrites, 0); @@ -483,7 +710,7 @@ void main() { break; } } - await tester.pumpAndSettle(); + await settleStorage(tester); final state = container.read(workspaceControllerProvider); expect(state.documentBuffers, hasLength(action == 'all' ? 0 : 1)); if (action == 'other') expect(state.activeBufferId, target.id); @@ -508,10 +735,114 @@ void main() { expect(find.text('Personal note'), findsOneWidget); expect(find.text('Shared note'), findsOneWidget); expect(find.text('Deleted note'), findsNothing); - await tester.enterText(find.byType(TextField), 'private search'); + await tester.tap(find.byType(TextField)); + await settleStorage(tester); + final dialog = find.byType(NotesSearchDialog); + final field = find.descendant( + of: dialog, + matching: find.byType(TextField), + ); + await tester.enterText(field, 'private search'); + await tester.pump(const Duration(milliseconds: 200)); + for (var i = 0; i < 30; i++) { + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 20)), + ); + await tester.pump(); + } + expect( + find.descendant(of: dialog, matching: find.text('Personal note')), + findsNWidgets(2), + ); + expect( + find.descendant(of: dialog, matching: find.text('Shared note')), + findsNothing, + ); + await tester.pumpWidget(const SizedBox.shrink()); + }, + ); + + testWidgets( + 'pointer and keyboard multiselection preserves reviewed context targets after resorting', + (tester) async { + late NextcloudNote alpha, beta, gamma; + await tester.runAsync(() async { + final account = repository.accountById(accountId)!; + await repository.removeAccount(accountId); + await repository.upsertAccount(account); + alpha = await repository.create(accountId, title: 'Alpha'); + beta = await repository.create(accountId, title: 'Beta'); + gamma = await repository.create(accountId, title: 'Gamma'); + }); + await pump(tester); + final sort = tester.widget>( + find.byType(DropdownButton), + ); + sort.onChanged!(NotesSort.titleAscending); await tester.pumpAndSettle(); - expect(find.text('Personal note'), findsOneWidget); - expect(find.text('Shared note'), findsNothing); + Finder row(NextcloudNote note) => + find.byKey(ValueKey('nextcloud-note-${note.localId}')); + bool selected(NextcloudNote note) => + tester.widget(row(note)).selected; + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.tap(row(alpha)); + await tester.tap(row(gamma)); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pump(); + expect(selected(alpha), true); + expect(selected(beta), false); + expect(selected(gamma), true); + await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); + await tester.tap(row(beta)); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); + await tester.pump(); + expect(selected(alpha), false); + expect(selected(beta), true); + expect(selected(gamma), true); + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyA); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pump(); + expect([alpha, beta, gamma].every(selected), true); + sort.onChanged!(NotesSort.titleDescending); + await tester.pumpAndSettle(); + expect([alpha, beta, gamma].every(selected), true); + await tester.tap(row(beta), buttons: kSecondaryButton); + await settleStorage(tester); + expect(find.text('Selected notes (3)'), findsNWidgets(2)); + // The menu has captured the reviewed identities and metadata. A durable + // update can reorder the list while the menu is open without retargeting. + await tester.runAsync( + () => repository.patchMetadata( + repository.metadataSnapshot(beta.localId), + title: 'Zeta', + ), + ); + await settleStorage(tester); + await tester.tap(find.text('Move to category')); + await settleStorage(tester); + final review = find.byType(BusyMarkDialogShell); + expect(review, findsOneWidget); + for (final title in ['Alpha', 'Beta', 'Gamma']) { + expect( + find.descendant(of: review, matching: find.text(title)), + findsOneWidget, + ); + } + expect( + find.descendant(of: review, matching: find.text('Zeta')), + findsNothing, + ); + await tester.tap( + find.descendant(of: review, matching: find.text('Cancel')), + ); + await settleStorage(tester); + expect(repository.noteById(beta.localId)!.title, 'Zeta'); + await tester.tap(find.text('Favorites').first); + await settleStorage(tester); + expect(find.text('Selected notes (3)'), findsNothing); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox.shrink()); }, ); @@ -519,16 +850,16 @@ void main() { 'favorites and ancestor category filtering operate on cached notes', (tester) async { await pump(tester); - await tester.tap(find.byType(CheckboxListTile).first); - await tester.pumpAndSettle(); + await tester.tap(find.text('Favorites').first); + await settleStorage(tester); expect(find.text('Shared note'), findsNothing); expect(find.text('Personal note'), findsOneWidget); - await tester.tap(find.byType(CheckboxListTile).first); - await tester.pumpAndSettle(); - await tester.tap(find.byType(DropdownButton)); - await tester.pumpAndSettle(); + await tester.tap(find.text('All notes').first); + await settleStorage(tester); + await tester.ensureVisible(find.text('Projects').last); + await settleStorage(tester); await tester.tap(find.text('Projects').last); - await tester.pumpAndSettle(); + await settleStorage(tester); expect(find.text('Shared note'), findsOneWidget); expect(find.text('Personal note'), findsNothing); }, @@ -538,10 +869,9 @@ void main() { tester, ) async { await pump(tester); - await tester.tap(find.byType(DropdownButton)); - await tester.pumpAndSettle(); + await tester.ensureVisible(find.text('Personal').last); await tester.tap(find.text('Personal').last); - await tester.pumpAndSettle(); + await settleStorage(tester); expect(find.text('Shared note'), findsNothing); await tester.runAsync( () => repository.save( @@ -550,14 +880,19 @@ void main() { category: '', ), ); - await tester.pumpAndSettle(); + await settleStorage(tester); expect(find.text('Personal note'), findsOneWidget); expect(find.text('Shared note'), findsOneWidget); expect( tester - .widget>(find.byType(DropdownButton)) - .value, - isNull, + .widget( + find.ancestor( + of: find.text('All notes'), + matching: find.byType(ListTile), + ), + ) + .selected, + isTrue, ); }); @@ -574,21 +909,20 @@ void main() { title: 'Move me', category: 'Last', ); - await repository.create(accountId, title: 'Uncategorized'); + await repository.create(accountId, title: 'Uncategorized draft'); }); await pump(tester); - await tester.tap(find.byType(DropdownButton)); - await tester.pumpAndSettle(); + await tester.ensureVisible(find.text('Last').last); await tester.tap(find.text('Last').last); - await tester.pumpAndSettle(); - expect(find.text('Uncategorized'), findsNothing); + await settleStorage(tester); + expect(find.text('Uncategorized draft'), findsNothing); await tester.runAsync( () => repository.save(categorized.localId, content: '', category: ''), ); - await tester.pumpAndSettle(); - expect(find.byType(DropdownButton), findsNothing); + await settleStorage(tester); + expect(find.text('Last'), findsNothing); expect(find.text('Move me'), findsOneWidget); - expect(find.text('Uncategorized'), findsOneWidget); + expect(find.text('Uncategorized draft'), findsOneWidget); }, ); @@ -614,18 +948,18 @@ void main() { ), ), ); - await tester.pumpAndSettle(); + await settleStorage(tester); await tester.tap(find.text('Compare')); - await tester.pumpAndSettle(); + await settleStorage(tester); await tester.runAsync( () async => Future.delayed(const Duration(milliseconds: 50)), ); - await tester.pumpAndSettle(); + await settleStorage(tester); expect(find.text('uncertain.pdf'), findsOneWidget); expect(find.text('Retry'), findsOneWidget); expect(find.text('Attachment reference on server'), findsOneWidget); await tester.tap(find.text('Cancel')); - await tester.pumpAndSettle(); + await settleStorage(tester); }, ); @@ -640,12 +974,19 @@ void main() { 'nextcloud-note-323e4567-e89b-42d3-a456-426614174000', ), ), - matching: find.byType(IconButton), + matching: find.byTooltip('Main menu'), ), ); - await tester.pumpAndSettle(); + await settleStorage(tester); + await tester.tap(find.text('Rename')); + await settleStorage(tester); final fields = tester - .widgetList(find.byType(TextField)) + .widgetList( + find.descendant( + of: find.byType(Dialog), + matching: find.byType(TextField), + ), + ) .toList(); expect(fields.where((field) => field.readOnly), hasLength(2)); expect(find.text('Save local copy…'), findsOneWidget); @@ -667,10 +1008,12 @@ void main() { 'nextcloud-note-223e4567-e89b-42d3-a456-426614174000', ), ), - matching: find.byType(IconButton), + matching: find.byTooltip('Main menu'), ), ); - await tester.pumpAndSettle(); + await settleStorage(tester); + await tester.tap(find.text('Rename')); + await settleStorage(tester); for ( var attempt = 0; attempt < 20 && find.text('report.pdf').evaluate().isEmpty; @@ -679,14 +1022,14 @@ void main() { await tester.runAsync( () => Future.delayed(const Duration(milliseconds: 25)), ); - await tester.pumpAndSettle(); + await settleStorage(tester); } expect(find.text('Add attachment…'), findsOneWidget); expect(find.text('report.pdf'), findsOneWidget); await tester.ensureVisible(find.byTooltip('Delete')); - await tester.pumpAndSettle(); + await settleStorage(tester); await tester.tap(find.byTooltip('Delete')); - await tester.pumpAndSettle(); + await settleStorage(tester); expect( find.text( 'Deleting this attachment makes its links in the note unavailable. Retained bytes can be recovered through local history.', @@ -694,7 +1037,7 @@ void main() { findsOneWidget, ); await tester.tap(find.text('Cancel').last); - await tester.pumpAndSettle(); + await settleStorage(tester); expect(find.text('report.pdf'), findsOneWidget); expect(tester.takeException(), isNull); }, @@ -735,14 +1078,14 @@ void main() { ), ); await tester.tap(find.text('Resolve')); - await tester.pumpAndSettle(); + await settleStorage(tester); expect(find.text('New note'), findsOneWidget); await tester.tap(find.text('Discard')); - await tester.pumpAndSettle(); + await settleStorage(tester); expect(find.text('Discard'), findsNWidgets(2)); expect(find.text('Recoverable edit'), findsOneWidget); await tester.tap(find.text('Cancel')); - await tester.pumpAndSettle(); + await settleStorage(tester); expect(find.text('Resolve'), findsOneWidget); expect(find.text('Discard'), findsNothing); expect(tester.takeException(), isNull); @@ -782,9 +1125,9 @@ void main() { ), ), ); - await tester.pumpAndSettle(); + await settleStorage(tester); await tester.tap(find.text('Compare')); - await tester.pumpAndSettle(); + await settleStorage(tester); final button = find.byWidgetPredicate( (w) => w is BusyMarkDialogButton && w.label == 'Merge', ); @@ -797,15 +1140,187 @@ void main() { ); // Exercise the actual dialog state callback and verify Apply enablement. dropdown.onChanged!(NotesMergeChoice.remote); - await tester.pumpAndSettle(); + await settleStorage(tester); } expect(tester.widget(button).onPressed, isNotNull); await tester.tap(find.text('Cancel')); - await tester.pumpAndSettle(); + await settleStorage(tester); expect(repository.noteById(id)!.title, 'Local title'); expect(tester.takeException(), isNull); }); + for (final draft in [false, true]) { + for (final choice in [ + 'Use previous edit', + 'Keep Mine', + 'Merge', + 'New note', + ]) { + testWidgets( + 'local metadata review applies the displayed choice: draft=$draft $choice', + (tester) async { + for (final channelName in ['yaru_window', 'yaru_window/events']) { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + MethodChannel(channelName), + (call) async => + call.method == 'state' ? {} : null, + ); + addTearDown( + () => TestDefaultBinaryMessengerBinding + .instance + .defaultBinaryMessenger + .setMockMethodCallHandler(MethodChannel(channelName), null), + ); + } + final id = draft + ? '223e4567-e89b-42d3-a456-426614174000' + : 'metadata-server'; + await tester.runAsync(() async { + final snapshot = repository.metadataSnapshot(id); + await repository.patchMetadata(snapshot, title: 'B'); + await repository.patchMetadata(snapshot, title: 'C'); + }); + tester.view.physicalSize = const Size(1200, 1000); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget( + ProviderScope( + overrides: [ + nextcloudNotesRepositoryProvider.overrideWith( + (ref) async => repository, + ), + localSettingsStoreProvider.overrideWithValue(_TabSettings()), + linuxAccentPlatformProvider.overrideWithValue(false), + localHistoryStoreProvider.overrideWithValue( + MemoryLocalHistoryStore(), + ), + documentSessionStoreProvider.overrideWithValue( + MemoryDocumentSessionStore(), + ), + documentRecoveryStoreProvider.overrideWithValue( + MemoryDocumentRecoveryStore(), + ), + ], + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: NextcloudNoteStatus(localId: id, unsaved: false), + ), + ), + ), + ); + await settleStorage(tester); + await tester.runAsync(() => tester.tap(find.text('Compare'))); + for ( + var i = 0; + i < 100 && find.text('Use previous edit').evaluate().isEmpty; + i++ + ) { + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 10)), + ); + await tester.pump(const Duration(milliseconds: 50)); + } + await settleStorage(tester); + expect(find.text('Use previous edit'), findsOneWidget); + expect(find.text('Take Remote'), findsNothing); + await tester.tap(find.byType(DropdownButton)); + await settleStorage(tester); + expect(find.text('Previous local edit: B'), findsOneWidget); + expect(find.text('Keep Mine: C'), findsOneWidget); + await tester.tap(find.text('Previous local edit: B')); + await settleStorage(tester); + await tester.runAsync(() async { + await tester.tap( + find.byWidgetPredicate( + (w) => w is BusyMarkDialogButton && w.label == choice, + ), + ); + }); + final completion = Stopwatch()..start(); + bool finished() => choice == 'New note' + ? repository.notes.any((n) => n.localId != id && n.title == 'C') + : repository.noteById(id)!.metadataConflict == null; + while (!finished() && + completion.elapsed < const Duration(seconds: 30)) { + await tester.pump(const Duration(milliseconds: 50)); + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 10)), + ); + } + await settleStorage(tester); + expect(finished(), isTrue); + final selected = choice == 'New note' + ? repository.notes.singleWhere( + (n) => n.localId != id && n.title == 'C', + ) + : repository.noteById(id)!; + expect( + selected.title, + choice == 'Keep Mine' || choice == 'New note' ? 'C' : 'B', + ); + expect(tester.takeException(), isNull); + final container = ProviderScope.containerOf( + tester.element(find.byType(NextcloudNoteStatus)), + ); + await tester.runAsync(() async { + container.dispose(); + }); + await tester.pumpWidget(const SizedBox.shrink()); + await tester.runAsync(() => repository.dispose()); + }, + ); + } + } + + for (final state in [NoteSyncState.pending, NoteSyncState.synced]) { + testWidgets('persisted metadata review shows conflict rather than $state', ( + tester, + ) async { + const id = 'metadata-server'; + await tester.runAsync(() async { + final snapshot = repository.metadataSnapshot(id); + await repository.patchMetadata(snapshot, title: 'B'); + await repository.patchMetadata(snapshot, title: 'C'); + final note = repository.noteById(id)!; + await repository.store.saveNote(note.copyWith(syncState: state)); + await repository.dispose(); + repository = _CachedNotesRepository( + store: await NotesStore.open( + path: p.join(root.path, 'notes.sqlite3'), + ), + ); + await repository.initialize(); + }); + await tester.pumpWidget( + ProviderScope( + overrides: [ + nextcloudNotesRepositoryProvider.overrideWith( + (ref) async => repository, + ), + ], + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const Scaffold( + body: NextcloudNoteStatus(localId: id, unsaved: false), + ), + ), + ), + ); + await settleStorage(tester); + expect(find.text('Conflicts'), findsOneWidget); + expect(find.text('Compare'), findsOneWidget); + expect(find.text('Synced'), findsNothing); + expect(find.text('Saved locally'), findsNothing); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox.shrink()); + }); + } + testWidgets('read-only favorite conflict offers a safe merge', ( tester, ) async { @@ -828,9 +1343,9 @@ void main() { ), ), ); - await tester.pumpAndSettle(); + await settleStorage(tester); await tester.tap(find.text('Compare')); - await tester.pumpAndSettle(); + await settleStorage(tester); final merge = find.byWidgetPredicate( (widget) => widget is BusyMarkDialogButton && widget.label == 'Merge', ); @@ -838,7 +1353,7 @@ void main() { expect(tester.widget(merge).onPressed, isNotNull); expect(find.text('Keep Mine'), findsNothing); await tester.tap(find.text('Cancel')); - await tester.pumpAndSettle(); + await settleStorage(tester); expect(tester.takeException(), isNull); }); @@ -864,9 +1379,9 @@ void main() { ), ), ); - await tester.pumpAndSettle(); + await settleStorage(tester); await tester.tap(find.text('Compare')); - await tester.pumpAndSettle(); + await settleStorage(tester); final takeRemote = find.byWidgetPredicate( (widget) => widget is BusyMarkDialogButton && @@ -876,11 +1391,11 @@ void main() { final candidates = find.byType(DropdownButton); expect(candidates, findsOneWidget); tester.widget>(candidates).onChanged!(10); - await tester.pumpAndSettle(); + await settleStorage(tester); expect(takeRemote, findsOneWidget); expect(find.textContaining('Sanitized A'), findsWidgets); await tester.tap(find.text('Cancel')); - await tester.pumpAndSettle(); + await settleStorage(tester); expect(repository.noteById('uncertain-create')!.serverId, isNull); expect(tester.takeException(), isNull); }); @@ -931,7 +1446,7 @@ void main() { ), ), ); - await tester.pumpAndSettle(); + await settleStorage(tester); await tester.runAsync(() => tester.tap(find.text('Compare'))); for ( var i = 0; @@ -944,9 +1459,9 @@ void main() { await tester.pump(const Duration(milliseconds: 50)); } await tester.tap(find.byType(DropdownButton)); - await tester.pumpAndSettle(); + await settleStorage(tester); await tester.tap(find.text('Sanitized A (#10)').last); - await tester.pumpAndSettle(); + await settleStorage(tester); expect(find.textContaining('identity is unconfirmed'), findsOneWidget); expect(find.textContaining('#10'), findsWidgets); await tester.runAsync( @@ -1009,14 +1524,14 @@ void main() { ), ), ); - await tester.pumpAndSettle(); + await settleStorage(tester); await tester.tap(find.text('Compare')); - await tester.pumpAndSettle(); + await settleStorage(tester); expect(find.text('New note'), findsOneWidget); expect(find.text('Merge'), findsNothing); expect(find.text('Keep Mine'), findsNothing); await tester.tap(find.text('Cancel')); - await tester.pumpAndSettle(); + await settleStorage(tester); expect(tester.takeException(), isNull); }, ); @@ -1054,5 +1569,10 @@ class _CachedNotesRepository extends NotesRepository { } @override - Future synchronize(String accountId) async {} + Future synchronize( + String accountId, { + bool allowWrites = true, + bool refreshCapabilities = false, + bool onlyFreshWrites = false, + }) async {} } diff --git a/test/src/nextcloud_workspace_test.dart b/test/src/nextcloud_workspace_test.dart index 3e355c85..92df0650 100644 --- a/test/src/nextcloud_workspace_test.dart +++ b/test/src/nextcloud_workspace_test.dart @@ -27,6 +27,45 @@ import 'nextcloud_notes/notes_api_test.dart' show serverNote; const _accountId = 'ba6d02cd-42fe-4e14-a577-3e2c13822459'; void main() { + test( + 'no-op properties Save preserves revision/time and schedules no server pass', + () async { + var gets = 0; + final harness = await _Harness.create( + seedLocal: false, + deferPersistence: true, + client: MockClient((request) async { + expect(request.method, 'GET'); + expect( + request.url.path, + '/nextcloud/index.php/apps/notes/api/v1/notes', + ); + gets++; + return http.Response(jsonEncode([serverNote(1)]), 200); + }), + ); + addTearDown(harness.dispose); + await harness.repository.synchronize(_accountId, allowWrites: false); + await harness.controller.openNextcloudWorkspace(_accountId); + await harness.controller.refreshNextcloudNotes(); + final note = harness.repository.notes.single; + final count = gets; + await harness.controller.updateNextcloudNoteMetadata( + note.localId, + snapshot: harness.repository.metadataSnapshot(note.localId), + ); + expect(gets, count); + expect(harness.repository.isSynchronizing(_accountId), isFalse); + expect( + harness.repository.noteById(note.localId)!.revision, + note.revision, + ); + expect( + harness.repository.noteById(note.localId)!.localActivityMicros, + note.localActivityMicros, + ); + }, + ); test( 'unsaved draft edits during confirmation require another review', () async { diff --git a/test/src/notes_workspace_m2_ui_test.dart b/test/src/notes_workspace_m2_ui_test.dart new file mode 100644 index 00000000..c96bd9b5 --- /dev/null +++ b/test/src/notes_workspace_m2_ui_test.dart @@ -0,0 +1,202 @@ +import 'package:busymark/l10n/generated/app_localizations.dart'; +import 'package:busymark/src/app/quick_open.dart'; +import 'package:busymark/src/app/busymark_design.dart'; +import 'package:busymark/src/app/command_registry.dart'; +import 'package:busymark/src/nextcloud_notes/application/notes_transfer_service.dart'; +import 'package:busymark/src/nextcloud_notes/presentation/notes_workspace_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +Future _drain(WidgetTester tester) async { + for (var i = 0; i < 25; i++) { + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 10)), + ); + await tester.pump(); + } +} + +Widget _app(Widget child) => MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold(body: child), +); +void main() { + test( + 'Quick Open ranks exact/prefix/title/path matches with stable identities', + () { + const docs = [ + QuickOpenDocument(title: 'foo backup', path: 'b', localId: 'b'), + QuickOpenDocument(title: 'foo', path: 'z', localId: 'z'), + QuickOpenDocument(title: 'other', path: 'foo/category', localId: 'o'), + QuickOpenDocument(title: 'a foo', path: 'a', filePath: '/real/a.md'), + QuickOpenDocument(title: 'foo', path: 'a', localId: 'a'), + ]; + expect(rankQuickOpen(docs, 'foo').map((d) => d.identity), [ + 'a', + 'z', + 'b', + '/real/a.md', + 'o', + ]); + expect(rankQuickOpen(docs, 'missing'), isEmpty); + expect( + BusyMarkCommandCatalog + .metadata[BusyMarkCommandIds.quickOpen]! + .shortcut! + .label, + 'Ctrl+P', + ); + expect( + BusyMarkCommandCatalog + .metadata[BusyMarkCommandIds.commandPalette]! + .shortcut! + .label, + 'Ctrl+Shift+P', + ); + }, + ); + testWidgets( + 'Quick Open arrow/Enter opens the chosen identity and Escape dismisses', + (tester) async { + QuickOpenDocument? result; + await tester.pumpWidget( + _app( + Builder( + builder: (context) => TextButton( + onPressed: () async { + result = await showDialog( + context: context, + builder: (_) => const QuickOpenDialog( + documents: [ + QuickOpenDocument(title: 'A', path: 'Cat', localId: 'a'), + QuickOpenDocument(title: 'B', path: 'Cat', localId: 'b'), + ], + ), + ); + }, + child: const Text('launch'), + ), + ), + ), + ); + await tester.tap(find.text('launch')); + await _drain(tester); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pumpAndSettle(); + expect(result?.localId, 'b'); + expect(result?.filePath, isNull); + await tester.tap(find.text('launch')); + await _drain(tester); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + expect(find.byType(QuickOpenDialog), findsNothing); + expect(result, isNull); + expect(tester.takeException(), isNull); + }, + ); + testWidgets( + 'import review exposes distinct collisions, missing media, category edits and selection', + (tester) async { + final item = NotesImportItem( + key: 'one', + path: '/source/one.md', + title: 'Same title', + category: 'Work/Sub', + content: 'body', + collision: true, + issues: ['missing.png'], + ); + final review = NotesImportReview('/source', [item], []); + await tester.pumpWidget(_app(NotesImportReviewDialog(review: review))); + await tester.pumpAndSettle(); + expect(find.text('Import as a distinct note'), findsOneWidget); + expect(find.text('Incomplete: missing.png'), findsOneWidget); + await tester.enterText(find.byType(TextFormField), 'Corrected/子'); + expect(item.category, 'Corrected/子'); + await tester.tap(find.byType(CheckboxListTile)); + await tester.pump(); + expect(item.selected, false); + final button = tester.widget( + find.widgetWithText(BusyMarkDialogButton, 'Import notes'), + ); + expect(button.onPressed, isNull); + expect(tester.takeException(), isNull); + }, + ); + testWidgets( + 'correction: import chooser distinguishes a new import from an explicit resumed operation', + (tester) async { + String? choice; + await tester.pumpWidget( + _app( + Builder( + builder: (context) => TextButton( + onPressed: () async { + choice = await showDialog( + context: context, + builder: (_) => const NotesImportOperationDialog( + operations: [ + { + 'id': 'reviewed-operation', + 'root': '/source', + 'created_at': 1780000000, + }, + ], + ), + ); + }, + child: const Text('launch'), + ), + ), + ), + ); + await tester.tap(find.text('launch')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Start a new import')); + await tester.pumpAndSettle(); + expect(choice, ''); + await tester.tap(find.text('launch')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Resume interrupted import')); + await tester.pumpAndSettle(); + expect(choice, 'reviewed-operation'); + final item = NotesImportItem( + key: 'one', + path: '/source/one.md', + title: 'Imported', + category: 'Reviewed/Sub', + content: 'body', + )..importedNoteId = 'stable-note'; + await tester.pumpWidget( + _app( + NotesImportReviewDialog( + review: NotesImportReview('/source', [item], []), + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('Already imported in this operation'), findsOneWidget); + expect( + tester + .widget(find.byType(CheckboxListTile)) + .onChanged, + isNull, + ); + expect( + tester + .widget( + find.descendant( + of: find.byType(TextFormField), + matching: find.byType(TextField), + ), + ) + .readOnly, + true, + ); + expect(tester.takeException(), isNull); + }, + ); +} diff --git a/test/src/references/markdown_move_service_test.dart b/test/src/references/markdown_move_service_test.dart new file mode 100644 index 00000000..d5dabe63 --- /dev/null +++ b/test/src/references/markdown_move_service_test.dart @@ -0,0 +1,560 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:busymark/src/references/markdown_move_service.dart'; +import 'package:busymark/src/workspace/workspace_service.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; + +void main() { + late Directory root; + const service = MarkdownMoveService(); + const workspaceService = WorkspaceService(); + setUp(() async { + root = await Directory.systemTemp.createTemp('markdown-move-'); + await Directory('${root.path}/old').create(); + await Directory('${root.path}/new').create(); + await File('${root.path}/old/target.md').writeAsString( + '# Heading\n[other](../other.md#id "title")\n![media](image.png)\n', + ); + await File('${root.path}/old/image.png').writeAsBytes([1, 2, 3]); + await File('${root.path}/other.md').writeAsString( + '# Other\n[link][ref] [again][ref]\n\n[ref]: old/target.md#heading "title"\n\n`old/target.md`\n', + ); + }); + tearDown(() async => root.delete(recursive: true)); + // Capture the actual durable pre-rename journal, then restore it after the + // successful operation to model process death immediately before deletion. + Future interruptedMove(MarkdownMovePlan plan) async { + final workspace = await workspaceService.openPath(root.path); + late File journal; + late List evidence; + await service.apply( + plan, + validateBuffers: () {}, + move: () async { + journal = + (await Directory( + '${root.path}/.busymark-refactoring', + ).list().where((e) => e.path.endsWith('.json')).toList()).single + as File; + evidence = await journal.readAsBytes(); + return p.equals(p.dirname(plan.source), p.dirname(plan.destination)) + ? workspaceService.renameEntity( + workspace, + plan.source, + p.basename(plan.destination), + ) + : workspaceService.moveEntity( + workspace, + plan.source, + p.dirname(plan.destination), + ); + }, + ); + await journal.writeAsBytes(evidence, flush: true); + return journal; + } + + test( + 'file rename updates incoming definitions once and leaves code unchanged', + () async { + final workspace = await workspaceService.openPath(root.path); + final old = '${root.path}/old/target.md', + target = '${root.path}/old/café %.md'; + final plan = await service.prepare(workspace, old, target); + expect(plan.files.length, 1); + expect(plan.files.single.edits.length, 1); + await service.apply( + plan, + validateBuffers: () {}, + move: () => + workspaceService.renameEntity(workspace, old, p.basename(target)), + ); + final incoming = await File('${root.path}/other.md').readAsString(); + expect( + incoming, + contains('[ref]: old/caf%C3%A9%20%25.md#heading "title"'), + ); + expect(incoming, contains('`old/target.md`')); + }, + ); + test('cross-directory move updates incoming and outgoing media', () async { + final workspace = await workspaceService.openPath(root.path); + final old = '${root.path}/old/target.md', + target = '${root.path}/new/target.md'; + final plan = await service.prepare(workspace, old, target); + expect(plan.files.length, 2); + await service.apply( + plan, + validateBuffers: () {}, + move: () => + workspaceService.moveEntity(workspace, old, '${root.path}/new'), + ); + expect( + await File(target).readAsString(), + contains('![media](../old/image.png)'), + ); + expect( + await File('${root.path}/other.md').readAsString(), + contains('new/target.md#heading'), + ); + }); + test( + 'complete folder mapping preserves internal media and updates incoming paths', + () async { + final workspace = await workspaceService.openPath(root.path); + final old = '${root.path}/old', target = '${root.path}/new/old'; + final plan = await service.prepare(workspace, old, target); + expect(plan.mapping['$old/image.png'], '$target/image.png'); + await service.apply( + plan, + validateBuffers: () {}, + move: () => + workspaceService.moveEntity(workspace, old, '${root.path}/new'), + ); + final source = await File('$target/target.md').readAsString(); + expect(source, contains('[other](../../other.md#id "title")')); + expect(source, contains('![media](image.png)')); + expect( + await File('${root.path}/other.md').readAsString(), + contains('new/old/target.md#heading'), + ); + }, + ); + test( + 'hidden authored sources participate in reviewed incoming links', + () async { + final hidden = File('${root.path}/.guides/.source.md'); + await hidden.parent.create(); + await hidden.writeAsString('[Target](../old/target.md#heading)\n'); + final workspace = await workspaceService.openPath(root.path); + final plan = await service.prepare( + workspace, + '${root.path}/old/target.md', + '${root.path}/old/renamed.md', + ); + expect(plan.files.any((f) => f.path == hidden.path), isTrue); + await service.apply( + plan, + validateBuffers: () {}, + move: () => workspaceService.renameEntity( + workspace, + '${root.path}/old/target.md', + 'renamed.md', + ), + ); + expect( + await hidden.readAsString(), + '[Target](../old/renamed.md#heading)\n', + ); + final reviewed = await service.prepare( + workspace, + '${root.path}/old/renamed.md', + '${root.path}/old/final.md', + ); + await File('${root.path}/.guides/new.md').writeAsString('new member'); + await expectLater(service.validate(reviewed), throwsStateError); + }, + ); + test( + 'folder moves maintain outgoing references in hidden descendants', + () async { + final hidden = File('${root.path}/old/.drafts/.source.md'); + await hidden.parent.create(); + await hidden.writeAsString( + '[Other](../../other.md)\n![Media](../image.png)\n', + ); + final workspace = await workspaceService.openPath(root.path); + final plan = await service.prepare( + workspace, + '${root.path}/old', + '${root.path}/new/old', + ); + await service.apply( + plan, + validateBuffers: () {}, + move: () => workspaceService.moveEntity( + workspace, + '${root.path}/old', + '${root.path}/new', + ), + ); + expect( + await File('${root.path}/new/old/.drafts/.source.md').readAsString(), + '[Other](../../../other.md)\n![Media](../image.png)\n', + ); + }, + ); + test( + 'external edits after review and new workspace members reject publication', + () async { + final workspace = await workspaceService.openPath(root.path); + final old = '${root.path}/old/target.md'; + final plan = await service.prepare( + workspace, + old, + '${root.path}/old/renamed.md', + ); + await File('${root.path}/other.md').writeAsString('new external bytes'); + await expectLater( + service.apply( + plan, + validateBuffers: () {}, + move: () => + workspaceService.renameEntity(workspace, old, 'renamed.md'), + ), + throwsStateError, + ); + expect(await File(old).exists(), isTrue); + final next = await service.prepare( + workspace, + old, + '${root.path}/old/renamed.md', + ); + await File('${root.path}/added.md').writeAsString('new source'); + await expectLater(service.validate(next), throwsStateError); + }, + ); + test('collision and symlink paths fail before writes', () async { + final workspace = await workspaceService.openPath(root.path); + final old = '${root.path}/old/target.md'; + await expectLater( + service.prepare(workspace, old, '${root.path}/other.md'), + throwsStateError, + ); + await Link('${root.path}/alias').create('${root.path}/old'); + await expectLater( + service.prepare( + workspace, + '${root.path}/alias/target.md', + '${root.path}/new/target.md', + ), + throwsA(anything), + ); + }); + test('move failure rolls back reviewed edits and retains source', () async { + final workspace = await workspaceService.openPath(root.path); + final before = await File('${root.path}/other.md').readAsString(); + final plan = await service.prepare( + workspace, + '${root.path}/old/target.md', + '${root.path}/old/renamed.md', + ); + await expectLater( + service.apply( + plan, + validateBuffers: () {}, + move: () async => throw const FileSystemException('injected denied'), + ), + throwsA(isA()), + ); + expect(await File('${root.path}/other.md').readAsString(), before); + expect(await File('${root.path}/old/target.md').exists(), isTrue); + }); + test( + 'restart recovery restores exact BOM/CRLF bytes and refuses newer external data', + () async { + final original = File('${root.path}/other.md'); + await original.writeAsBytes([ + 239, + 187, + 191, + ...utf8.encode('[link](old/target.md#heading)\r\n'), + ]); + final workspace = await workspaceService.openPath(root.path); + final plan = await service.prepare( + workspace, + '${root.path}/old/target.md', + '${root.path}/new/target.md', + ); + final journal = await interruptedMove(plan); + const restarted = MarkdownMoveService(); + final review = await restarted.prepareRecovery(root.path, journal.path); + await original.writeAsString('new external edit'); + await expectLater(restarted.recoverReviewed(review), throwsStateError); + expect(await original.readAsString(), 'new external edit'); + expect(await journal.exists(), isTrue); + final changedFile = plan.files.singleWhere( + (f) => f.path == original.path, + ); + await original.writeAsBytes( + changedFile.load.format.encode(changedFile.result), + ); + await restarted.recover(root.path, journal.path); + expect(await File(plan.source).exists(), isTrue); + expect(await File(plan.destination).exists(), isFalse); + expect( + await original.readAsBytes(), + changedFile.load.format.encode(changedFile.load.text), + ); + expect(await journal.exists(), isFalse); + }, + ); + for (final change in [ + 'recreated source', + 'replaced destination', + 'identical-byte destination replacement', + ]) { + for (final afterReview in [false, true]) { + test( + 'restart recovery retains evidence for $change (after review: $afterReview)', + () async { + final source = File('${root.path}/old/target.md'); + await source.writeAsString('# Target without outgoing references\n'); + final incoming = File('${root.path}/other.md'); + final workspace = await workspaceService.openPath(root.path); + final plan = await service.prepare( + workspace, + source.path, + '${root.path}/old/renamed.md', + ); + expect(plan.files.any((f) => f.path == source.path), isFalse); + final journal = await interruptedMove(plan); + final reviewed = afterReview + ? await const MarkdownMoveService().prepareRecovery( + root.path, + journal.path, + ) + : null; + final evidence = await journal.readAsBytes(); + final linked = await incoming.readAsBytes(); + final destination = File(plan.destination); + if (change == 'recreated source') { + await source.writeAsString('# Unrelated newly created source\n'); + } else { + final bytes = await destination.readAsBytes(); + await destination.rename('${plan.destination}.original'); + await destination.writeAsBytes( + change == 'replaced destination' + ? utf8.encode('# Unrelated replacement destination\n') + : bytes, + ); + } + final destinationBytes = await destination.readAsBytes(); + final sourceBytes = await source.exists() + ? await source.readAsBytes() + : null; + if (reviewed == null) { + await expectLater( + const MarkdownMoveService().recover(root.path, journal.path), + throwsStateError, + ); + } else { + await expectLater( + const MarkdownMoveService().recoverReviewed(reviewed), + throwsStateError, + ); + } + expect(await incoming.readAsBytes(), linked); + expect(await destination.readAsBytes(), destinationBytes); + if (sourceBytes != null) { + expect(await source.readAsBytes(), sourceBytes); + } else { + expect(await source.exists(), isFalse); + } + expect(await journal.readAsBytes(), evidence); + }, + ); + } + } + test( + 'legacy identity-free recovery evidence is kept for manual review', + () async { + final journal = File('${root.path}/.busymark-refactoring/legacy.json'); + await journal.parent.create(); + await journal.writeAsString( + jsonEncode({ + 'version': 1, + 'source': 'old/target.md', + 'destination': 'old/renamed.md', + 'files': [], + }), + ); + final before = await journal.readAsString(); + await expectLater( + service.recover(root.path, journal.path), + throwsStateError, + ); + expect(await journal.readAsString(), before); + }, + ); + test( + 'replaced folder identity and added membership retain restart evidence', + () async { + final workspace = await workspaceService.openPath(root.path); + final plan = await service.prepare( + workspace, + '${root.path}/old', + '${root.path}/new/old', + ); + final journal = await interruptedMove(plan); + final incoming = await File('${root.path}/other.md').readAsBytes(); + await File('${plan.destination}/unreviewed.png').writeAsBytes([9]); + await expectLater( + service.recover(root.path, journal.path), + throwsStateError, + ); + expect(await File('${root.path}/other.md').readAsBytes(), incoming); + expect(await journal.exists(), isTrue); + await File('${plan.destination}/unreviewed.png').delete(); + // Retain all original child identities but replace the containing directory. + await Directory(plan.destination).rename('${plan.destination}.original'); + await Directory(plan.destination).create(); + await for (final entry in Directory( + '${plan.destination}.original', + ).list()) { + await File( + entry.path, + ).rename(p.join(plan.destination, p.basename(entry.path))); + } + await expectLater( + service.recover(root.path, journal.path), + throwsStateError, + ); + expect(await File('${root.path}/other.md').readAsBytes(), incoming); + expect(await journal.exists(), isTrue); + }, + ); + test( + 'recreated source during publication prevents journal deletion and reference rollback', + () async { + final workspace = await workspaceService.openPath(root.path); + final source = File('${root.path}/old/target.md'); + final plan = await service.prepare( + workspace, + source.path, + '${root.path}/old/renamed.md', + ); + await expectLater( + service.apply( + plan, + validateBuffers: () {}, + move: () async { + final moved = await workspaceService.renameEntity( + workspace, + source.path, + 'renamed.md', + ); + await source.writeAsString('# Unrelated recreated source'); + return moved; + }, + ), + throwsStateError, + ); + expect(await source.readAsString(), '# Unrelated recreated source'); + expect(await File(plan.destination).exists(), isTrue); + expect( + await File('${root.path}/other.md').readAsString(), + contains('old/renamed.md#heading'), + ); + expect( + await Directory( + '${root.path}/.busymark-refactoring', + ).list().where((e) => e.path.endsWith('.json')).length, + 1, + ); + }, + ); + test( + 'valid code-formatted labels update through ordinary rename and move', + () async { + final file = File('${root.path}/other.md'); + const before = + '[`API`](old/target.md "title") [`API`][ref]\n\n' + '[ref]: old/target.md#heading "definition title"\n\n' + '`[example](old/target.md)`\n\n'; + await file.writeAsString(before); + final workspace = await workspaceService.openPath(root.path); + final rename = await service.prepare( + workspace, + '${root.path}/old/target.md', + '${root.path}/old/renamed.md', + ); + await service.apply( + rename, + validateBuffers: () {}, + move: () => workspaceService.renameEntity( + workspace, + rename.source, + 'renamed.md', + ), + ); + final move = await service.prepare( + workspace, + rename.destination, + '${root.path}/new/renamed.md', + ); + await service.apply( + move, + validateBuffers: () {}, + move: () => workspaceService.moveEntity( + workspace, + move.source, + '${root.path}/new', + ), + ); + expect( + await file.readAsString(), + '[`API`](new/renamed.md "title") [`API`][ref]\n\n' + '[ref]: new/renamed.md#heading "definition title"\n\n' + '`[example](old/target.md)`\n\n', + ); + }, + ); + test( + 'replacement during recovery writes rolls references back and retains evidence', + () async { + final source = File('${root.path}/old/target.md'); + await source.writeAsString('# Target without internal links'); + final workspace = await workspaceService.openPath(root.path); + final plan = await service.prepare( + workspace, + source.path, + '${root.path}/old/renamed.md', + ); + final journal = await interruptedMove(plan); + final incoming = File('${root.path}/other.md'); + final linked = await incoming.readAsBytes(); + final evidence = await journal.readAsBytes(); + final restarted = MarkdownMoveService( + workspaceService: WorkspaceService( + afterBatchFileCommit: (_, _) async { + await source.rename('${source.path}.original'); + await source.writeAsString('# Unrelated concurrent replacement'); + }, + ), + ); + await expectLater( + restarted.recover(root.path, journal.path), + throwsStateError, + ); + expect(await incoming.readAsBytes(), linked); + expect(await source.readAsString(), '# Unrelated concurrent replacement'); + expect(await journal.readAsBytes(), evidence); + }, + ); + test( + 'changed non-Markdown folder membership and replacement identity require renewed review', + () async { + final workspace = await workspaceService.openPath(root.path); + final plan = await service.prepare( + workspace, + '${root.path}/old', + '${root.path}/new/old', + ); + await File('${root.path}/old/new.png').writeAsBytes([9]); + await expectLater(service.validate(plan), throwsStateError); + final next = await service.prepare( + workspace, + '${root.path}/old/target.md', + '${root.path}/old/renamed.md', + ); + final file = File('${root.path}/other.md'); + final content = await file.readAsString(); + await file.delete(); + await file.writeAsString(content); + await expectLater(service.validate(next), throwsStateError); + }, + ); +} diff --git a/test/src/references/markdown_references_test.dart b/test/src/references/markdown_references_test.dart new file mode 100644 index 00000000..9ecb9ddc --- /dev/null +++ b/test/src/references/markdown_references_test.dart @@ -0,0 +1,84 @@ +import 'package:busymark/src/references/markdown_references.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test( + 'repeated reference occurrences retain one exact definition destination', + () { + const source = + '[one][ref] [two][ref] [ref][] [ref]\n\n[ref]: "title"\n'; + final refs = markdownReferences(identity: 'a.md', source: source); + expect(refs.length, 4); + expect(refs.map((r) => r.occurrence.startOffset).toSet().length, 4); + expect(refs.map((r) => r.destinationSpan!.startOffset).toSet().length, 1); + for (final ref in refs) { + expect( + source.substring( + ref.destinationSpan!.startOffset, + ref.destinationSpan!.endOffset, + ), + 'café%20file.md#id', + ); + } + }, + ); + test('inline destinations preserve titles balanced paths and HTML spans', () { + const source = + '[one](a_(b).md#id "title") HTML\n'; + final refs = markdownReferences(identity: 'a.md', source: source); + expect(refs.length, 2); + expect( + refs.map( + (r) => source.substring( + r.destinationSpan!.startOffset, + r.destinationSpan!.endOffset, + ), + ), + ['a_(b).md#id', 'other.md#id'], + ); + }); + test('code, escaped syntax, comments and losing definitions are excluded', () { + const source = + r'\[escape](bad.md)' + '\n`[inline](bad.md)`\n\n [indented](bad.md)\n\n```\n[fenced](bad.md)\n```\n\n\n[ok][ref]\n\n[ref]: good.md\n[ref]: ignored.md\n'; + final refs = markdownReferences(identity: 'a.md', source: source); + expect(refs.map((r) => r.destination), ['good.md']); + }); + test('code formatting inside genuine link labels is not a code example', () { + const source = + '[`API`](target.md "title") [`API`][ref] [`API`][] [`API`]\n\n' + '[ref]: target.md#section\n[`API`]: target.md\n\n' + '`[example](bad.md)`\n\n```\n[`example`](bad.md)\n```\n\n' + '\n'; + final refs = markdownReferences(identity: 'source.md', source: source); + expect(refs.map((r) => r.destination), [ + 'target.md', + 'target.md#section', + 'target.md', + 'target.md', + ]); + expect(refs.map((r) => r.kind), [ + AuthoredReferenceKind.link, + AuthoredReferenceKind.referenceLink, + AuthoredReferenceKind.referenceLink, + AuthoredReferenceKind.referenceLink, + ]); + expect( + source.substring( + refs.first.occurrence.startOffset, + refs.first.occurrence.endOffset, + ), + '[`API`](target.md "title")', + ); + expect(refs[2].destinationSpan, refs[3].destinationSpan); + }); + test( + 'multiline container definitions use parser-winning source positions', + () { + const source = '> [text][ref]\n>\n> [ref]:\n> "title"\n'; + final refs = markdownReferences(identity: 'a.md', source: source); + expect(refs.length, 1); + expect(refs.single.destination, 'other.md'); + }, + ); +} diff --git a/test/src/references/reference_controller_test.dart b/test/src/references/reference_controller_test.dart new file mode 100644 index 00000000..64be8d68 --- /dev/null +++ b/test/src/references/reference_controller_test.dart @@ -0,0 +1,216 @@ +import 'dart:io'; +import 'dart:async'; +import 'package:busymark/src/references/reference_controller.dart'; +import 'package:busymark/src/references/reference_index.dart'; +import 'package:busymark/src/workspace/workspace_controller.dart'; +import 'package:busymark/src/workspace/workspace_model.dart'; +import 'package:busymark/src/workspace/workspace_service.dart'; +import 'package:busymark/src/workspace/document_buffer.dart'; +import 'package:busymark/src/nextcloud_notes/application/nextcloud_connection.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class IndexedWorkspace extends WorkspaceController { + IndexedWorkspace(this.initial); + final WorkspaceState initial; + @override + WorkspaceState build() => initial; + void publish(WorkspaceState value) => state = value; +} + +void main() { + test( + 'edits during initial indexing reuse accepted batches and reject obsolete results', + () async { + final root = await Directory.systemTemp.createTemp('reference-batches-'); + addTearDown(() => root.delete(recursive: true)); + for (var i = 0; i < 33; i++) { + await File('${root.path}/$i.md').writeAsString('# Heading'); + } + final workspace = await const WorkspaceService().openPath(root.path); + final held = Completer>(); + final started = Completer>(); + final batches = >[]; + final container = ProviderContainer( + overrides: [ + workspaceControllerProvider.overrideWith( + () => IndexedWorkspace(WorkspaceState(workspace: workspace)), + ), + nextcloudNotesRepositoryProvider.overrideWith( + (_) async => throw StateError('Local fixture'), + ), + referenceBatchParserProvider.overrideWithValue((documents) async { + batches.add(documents); + if (batches.length == 2) { + started.complete(documents); + return held.future; + } + return documents.map(ParsedReferenceDocument.new).toList(); + }), + ], + ); + addTearDown(container.dispose); + final subscription = container.listen(referenceIndexProvider, (_, _) {}); + addTearDown(subscription.close); + final obsolete = await started.future.timeout( + const Duration(seconds: 10), + ); + final edited = batches.first.first; + final controller = + container.read(workspaceControllerProvider.notifier) + as IndexedWorkspace; + controller.publish( + WorkspaceState( + workspace: workspace, + activeBufferId: 'dirty', + documentBuffers: [ + DocumentBuffer( + id: 'dirty', + filePath: edited.path, + dirty: true, + text: '# Changed\n[target](32.md)', + lastSavedText: '# Heading', + ), + ], + ), + ); + held.complete(obsolete.map(ParsedReferenceDocument.new).toList()); + final deadline = DateTime.now().add(const Duration(seconds: 10)); + ReferenceIndex? result; + while (DateTime.now().isBefore(deadline)) { + final current = container.read(referenceIndexProvider).value; + if (current?.documents[edited.identity]?.source.contains('# Changed') == + true) { + result = current; + break; + } + await Future.delayed(const Duration(milliseconds: 25)); + } + expect(result, isNotNull); + expect(result!.documents, hasLength(33)); + expect(result.references.single.resolution, ReferenceResolution.resolved); + expect( + result.references.single.target!.document.path, + '${root.path}/32.md', + ); + expect(batches.every((b) => b.length <= 16), isTrue); + for (final unchanged in batches.first.skip(1)) { + expect( + batches + .expand((b) => b) + .where((d) => d.identity == unchanged.identity), + hasLength(1), + ); + } + expect( + batches.expand((b) => b).where((d) => d.identity == edited.identity), + hasLength(2), + ); + expect(await File(edited.path!).readAsString(), '# Heading'); + }, + ); + test( + 'unopened files, dirty overlays, external edits, deletion/recovery and workspace removal invalidate derived data', + () async { + final root = await Directory.systemTemp.createTemp('reference-overlay-'); + addTearDown(() => root.delete(recursive: true)); + final source = File('${root.path}/source.md'), + target = File('${root.path}/target.md'); + await source.writeAsString('[old](target.md#old)'); + await target.writeAsString('# Old'); + const service = WorkspaceService(); + var workspace = await service.openPath(root.path); + final container = ProviderContainer( + overrides: [ + workspaceControllerProvider.overrideWith( + () => IndexedWorkspace(WorkspaceState(workspace: workspace)), + ), + nextcloudNotesRepositoryProvider.overrideWith( + (_) async => throw StateError('Local fixture'), + ), + ], + ); + addTearDown(container.dispose); + final subscription = container.listen(referenceIndexProvider, (_, _) {}); + addTearDown(subscription.close); + final controller = + container.read(workspaceControllerProvider.notifier) + as IndexedWorkspace; + Future settle( + bool Function(ReferenceIndex) predicate, + ) async { + final deadline = DateTime.now().add(const Duration(seconds: 20)); + while (DateTime.now().isBefore(deadline)) { + final value = container.read(referenceIndexProvider).value; + if (value != null && predicate(value)) return value; + await Future.delayed(const Duration(milliseconds: 25)); + } + throw StateError( + 'Index did not settle: ${container.read(referenceIndexProvider)}', + ); + } + + await settle( + (i) => + i.references.singleOrNull?.resolution == + ReferenceResolution.resolved, + ); + controller.publish( + WorkspaceState( + workspace: workspace, + activeBufferId: 'dirty', + documentBuffers: [ + DocumentBuffer( + id: 'dirty', + filePath: source.path, + text: '[new](target.md#new)', + lastSavedText: await source.readAsString(), + dirty: true, + ), + ], + ), + ); + await settle( + (i) => + i.references.singleOrNull?.authored.destination == 'target.md#new', + ); + expect(await source.readAsString(), '[old](target.md#old)'); + await target.writeAsString('# New'); + controller.publish( + container.read(workspaceControllerProvider).copyWith(), + ); + await settle( + (i) => + i.references.singleOrNull?.resolution == + ReferenceResolution.resolved && + i.references.single.authored.destination.endsWith('#new'), + ); + await target.delete(); + workspace = await service.openPath(root.path); + controller.publish( + container + .read(workspaceControllerProvider) + .copyWith(workspace: workspace), + ); + await settle( + (i) => + i.references.singleOrNull?.resolution == + ReferenceResolution.missing, + ); + await target.writeAsString('# New'); + workspace = await service.openPath(root.path); + controller.publish( + container + .read(workspaceControllerProvider) + .copyWith(workspace: workspace), + ); + await settle( + (i) => + i.references.singleOrNull?.resolution == + ReferenceResolution.resolved, + ); + controller.publish(const WorkspaceState()); + await settle((i) => i.documents.isEmpty); + }, + ); +} diff --git a/test/src/references/reference_index_test.dart b/test/src/references/reference_index_test.dart new file mode 100644 index 00000000..f155616b --- /dev/null +++ b/test/src/references/reference_index_test.dart @@ -0,0 +1,286 @@ +import 'package:busymark/src/references/reference_index.dart'; +import 'package:busymark/src/editor/wysiwyg/writerside_editing_adapter.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + ParsedReferenceDocument doc( + String name, + String source, { + bool available = true, + }) => ParsedReferenceDocument( + ReferenceDocument( + identity: '/workspace/$name', + path: '/workspace/$name', + title: name, + source: source, + available: available, + ), + ); + test( + 'actual parser heading IDs distinguish duplicate names and Unicode targets', + () { + final target = doc('café.md', '# Title\n## Repeated\n## Repeated\n'); + final anchors = target.headings.map((h) => h.id).toList(); + expect(anchors.toSet().length, anchors.length); + final source = doc( + 'source.md', + '[one](caf%C3%A9.md#${anchors[1]}) [two](café.md#${anchors[2]}) [broken](café.md#absent)', + ); + final index = ReferenceIndex( + parsed: [source, target], + root: '/workspace', + ); + expect(index.references.map((r) => r.resolution), [ + ReferenceResolution.resolved, + ReferenceResolution.resolved, + ReferenceResolution.missing, + ]); + expect( + index + .query(target.document.identity, incoming: true, anchor: anchors[1]) + .total, + 1, + ); + }, + ); + test( + 'unavailable files and uncached note IDs do not imply nonexistent targets', + () { + final index = ReferenceIndex( + parsed: [ + doc('source.md', '[one](unreadable.md) [two](missing.md)'), + doc('unreadable.md', '', available: false), + ], + root: '/workspace', + ); + expect(index.references.map((r) => r.resolution), [ + ReferenceResolution.unavailable, + ReferenceResolution.missing, + ]); + final notes = ReferenceIndex( + parsed: [ + ParsedReferenceDocument( + ReferenceDocument( + identity: 'note', + title: 'Note', + source: + '[uncached](https://cloud.example/nc/index.php/apps/notes/note/42)', + localId: 'note', + serverId: 1, + ), + ), + ], + server: Uri.parse('https://cloud.example/nc'), + ); + expect( + notes.references.single.resolution, + ReferenceResolution.unavailable, + ); + }, + ); + test( + 'code-formatted inline and reference labels remain incoming references', + () { + final target = doc('target.md', '# Target'); + final index = ReferenceIndex( + parsed: [ + doc( + 'source.md', + '[`API`](target.md) [`API`][ref]\n\n[ref]: target.md\n' + '`[example](target.md)`\n', + ), + target, + ], + root: '/workspace', + ); + final incoming = index.query(target.document.identity, incoming: true); + expect(incoming.total, 2); + expect( + incoming.values.map((r) => r.resolution), + everyElement(ReferenceResolution.resolved), + ); + }, + ); + test('encoded Unicode and literal percent anchors decode exactly once', () { + final target = doc( + 'target.md', + '# Café\n\n## Explicit {id="élément%20id"}\n', + ); + expect(target.headings.map((h) => h.id), ['café', 'élément%20id']); + final source = doc( + 'source.md', + '[heading](target.md#caf%C3%A9) ' + '[explicit](target.md#%C3%A9l%C3%A9ment%2520id) ' + '[wrong](target.md#%C3%A9l%C3%A9ment%20id)', + ); + final index = ReferenceIndex(parsed: [source, target], root: '/workspace'); + expect(index.references.map((r) => r.resolution), [ + ReferenceResolution.resolved, + ReferenceResolution.resolved, + ReferenceResolution.missing, + ]); + for (var i = 0; i < 2; i++) { + final reference = index.references[i]; + expect(reference.target!.anchor, target.headings[i].id); + expect(reference.target!.span, target.headings[i].span); + expect( + index + .query( + target.document.identity, + incoming: true, + anchor: target.headings[i].id, + ) + .total, + 1, + ); + } + expect(index.references.first.authored.destination, 'target.md#caf%C3%A9'); + }); + test('every reference remains reachable through explicit continuation', () { + final source = doc( + 'source.md', + List.generate(357, (i) => '[link $i](target.md)').join(' '), + ); + final index = ReferenceIndex( + parsed: [source, doc('target.md', '# Target')], + root: '/workspace', + ); + final offsets = []; + int? cursor = 0; + while (cursor != null) { + final page = index.query(source.document.identity, offset: cursor); + offsets.addAll(page.values.map((r) => r.authored.occurrence.startOffset)); + cursor = page.nextOffset; + } + expect(offsets.length, 357); + expect(offsets.toSet().length, 357); + }); + test( + 'known deleted IDs invalidate cached availability and never retarget recovery', + () { + final parsed = [ + ParsedReferenceDocument( + ReferenceDocument( + identity: 'source', + title: 'Source', + localId: 'source', + serverId: 1, + source: + '[old](https://cloud.example/nc/apps/notes/note/42) ' + '[recovered](https://cloud.example/nc/apps/notes/note/43) ' + '[uncached](https://cloud.example/nc/apps/notes/note/44)', + ), + ), + ParsedReferenceDocument( + ReferenceDocument( + identity: 'recovered', + title: 'Recovered', + localId: 'recovered', + serverId: 43, + source: '# Recovered', + ), + ), + ]; + final server = Uri.parse('https://cloud.example/nc'); + final before = ReferenceIndex(parsed: parsed, server: server); + expect( + before.references.first.resolution, + ReferenceResolution.unavailable, + ); + final missing = {42}; + final after = ReferenceIndex( + parsed: parsed, + server: server, + previous: before, + missingNoteIds: missing, + ); + missing.clear(); + expect(after.missingNoteIds, {42}); + expect(after.references.map((r) => r.resolution), [ + ReferenceResolution.missing, + ReferenceResolution.resolved, + ReferenceResolution.unavailable, + ]); + expect(after.references.first.target, isNull); + expect(after.references[1].target!.document.identity, 'recovered'); + }, + ); + test('missing supported Markdown extension remains a missing document', () { + final index = ReferenceIndex( + parsed: [doc('source.md', '[missing](target.markdown)')], + root: '/workspace', + ); + expect(index.references.single.resolution, ReferenceResolution.missing); + }); + test('incremental reuse refreshes repaired anchors and changed targets', () { + final source = doc('source.md', '[missing](target.md#added)'); + final untouched = doc('untouched.md', '[target](target.md)'); + final target = doc('target.md', '# Target'); + final initial = ReferenceIndex( + parsed: [source, untouched, target], + root: '/workspace', + ); + final updated = ReferenceIndex( + parsed: [source, untouched, doc('target.md', '# Target\n## Added')], + root: '/workspace', + previous: initial, + ); + expect( + updated.query(source.document.identity).values.single.resolution, + ReferenceResolution.resolved, + ); + expect(updated.query(target.document.identity, incoming: true).total, 2); + final unavailable = ReferenceIndex( + parsed: [source, untouched, doc('target.md', '', available: false)], + root: '/workspace', + previous: updated, + ); + expect( + unavailable.references.map((r) => r.resolution), + everyElement(ReferenceResolution.unavailable), + ); + }); + test( + 'XML source link uses semantic adapter and preserves namespace and formatting', + () { + const source = + 'one bold two'; + final start = source.indexOf(''), end = source.indexOf(' two'); + final result = const WritersideEditingAdapter().insertSourceLink( + filePath: 'a.topic', + source: source, + start: start, + end: end, + destination: 'other.topic#id', + placeholder: 'text', + ); + expect( + result, + contains('bold'), + ); + expect( + result, + startsWith('one '), + ); + }, + ); + test( + 'XML attribute and partial element selections cannot receive a link', + () { + const source = '

text bold

'; + for (final range in [(start: 11, end: 12), (start: 23, end: 25)]) { + expect( + const WritersideEditingAdapter().insertSourceLink( + filePath: 'a.topic', + source: source, + start: range.start, + end: range.end, + destination: 'b.topic', + placeholder: 'text', + ), + isNull, + ); + } + }, + ); +} diff --git a/test/src/references/reference_ui_test.dart b/test/src/references/reference_ui_test.dart new file mode 100644 index 00000000..d3816072 --- /dev/null +++ b/test/src/references/reference_ui_test.dart @@ -0,0 +1,309 @@ +import 'package:busymark/src/editor/link_destination.dart'; +import 'package:busymark/src/app/app_settings.dart'; +import 'package:busymark/src/nextcloud_notes/presentation/notes_workspace_ui.dart'; +import 'package:busymark/l10n/generated/app_localizations.dart'; +import 'package:busymark/src/references/reference_controller.dart'; +import 'package:busymark/src/references/reference_index.dart'; +import 'package:busymark/src/references/reference_ui.dart'; +import 'package:busymark/src/workspace/document_buffer.dart'; +import 'package:busymark/src/workspace/workspace_controller.dart'; +import 'package:busymark/src/workspace/workspace_model.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class FixtureIndex extends ReferenceController { + FixtureIndex(this.index); + final ReferenceIndex index; + @override + void refresh() {} + @override + AsyncValue build() => AsyncData(index); +} + +class FixtureWorkspace extends WorkspaceController { + @override + Future openActiveFile(String path) async => true; + @override + WorkspaceState build() => WorkspaceState( + workspace: Workspace( + id: 'fixture', + rootPath: '/workspace', + kind: WorkspaceKind.markdownFolder, + openedAt: DateTime(2026), + files: const [], + diagnostics: const [], + ), + activeBufferId: 'source', + documentBuffers: [ + DocumentBuffer( + id: 'source', + filePath: '/workspace/source.md', + text: 'source', + lastSavedText: 'source', + dirty: false, + ), + ], + ); +} + +class NavigableFixtureWorkspace extends FixtureWorkspace { + NavigableFixtureWorkspace(this.index); + final ReferenceIndex index; + DocumentViewModePreference? navigatedMode; + @override + void updateActiveEditorMode(DocumentViewModePreference mode) { + navigatedMode = mode; + } + + @override + WorkspaceState build() => super.build().copyWith( + documentBuffers: [ + DocumentBuffer( + id: 'source', + filePath: '/workspace/source.md', + text: index.documents['/workspace/source.md']!.source, + lastSavedText: index.documents['/workspace/source.md']!.source, + dirty: false, + ), + ], + ); + @override + Future openActiveFile(String path) async { + final doc = index.documents[path]!; + state = state.copyWith( + activeBufferId: path, + documentBuffers: [ + ...state.documentBuffers.where((b) => b.id != path), + DocumentBuffer( + id: path, + filePath: path, + text: doc.source, + lastSavedText: doc.source, + dirty: false, + ), + ], + ); + return true; + } +} + +void main() { + final index = ReferenceIndex( + root: '/workspace', + parsed: [ + ParsedReferenceDocument( + const ReferenceDocument( + identity: '/workspace/source.md', + path: '/workspace/source.md', + title: 'Source', + source: '[target](target.md#actual)', + ), + ), + ParsedReferenceDocument( + const ReferenceDocument( + identity: '/workspace/target.md', + path: '/workspace/target.md', + title: 'Target', + source: '# Actual', + ), + ), + ], + ); + Widget app(Widget home) => ProviderScope( + overrides: [ + referenceIndexProvider.overrideWith(() => FixtureIndex(index)), + workspaceControllerProvider.overrideWith(FixtureWorkspace.new), + refactoringRecoveryProvider.overrideWith((_) async => []), + ], + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold(body: home), + ), + ); + testWidgets('panel shows scope, outgoing status and incoming switch', ( + tester, + ) async { + await tester.pumpWidget(app(const ReferencesPanel())); + await tester.pumpAndSettle(); + expect(find.text('/workspace'), findsOneWidget); + expect(find.text('Target'), findsOneWidget); + expect(find.textContaining('Resolved'), findsOneWidget); + await tester.tap(find.text('Incoming')); + await tester.pumpAndSettle(); + expect(find.text('Target'), findsNothing); + }); + testWidgets( + 'stale source occurrence refreshes without selecting unrelated text', + (tester) async { + await tester.pumpWidget(app(const ReferencesPanel())); + await tester.pumpAndSettle(); + await tester.tap(find.byType(PopupMenuButton)); + await tester.pumpAndSettle(); + await tester.tap(find.text('Source location')); + await tester.pumpAndSettle(); + final container = ProviderScope.containerOf( + tester.element(find.byType(ReferencesPanel)), + ); + expect(container.read(notesMatchNavigationProvider), isNull); + }, + ); + testWidgets( + 'chooser searches actual heading targets and preserves manual URLs', + (tester) async { + LinkDestination? result; + await tester.pumpWidget( + app( + Builder( + builder: (context) => TextButton( + onPressed: () async { + result = await showReferenceLinkChooser(context); + }, + child: const Text('Choose'), + ), + ), + ), + ); + await tester.tap(find.text('Choose')); + await tester.pumpAndSettle(); + expect(find.text('Choose link target'), findsOneWidget); + await tester.enterText(find.byType(TextField).last, 'Actual'); + await tester.pumpAndSettle(); + expect(find.text('Actual'), findsWidgets); + await tester.tap(find.text('target.md#actual')); + await tester.pumpAndSettle(); + expect(result?.href, 'target.md#actual'); + await tester.tap(find.text('Choose')); + await tester.pumpAndSettle(); + await tester.enterText( + find.byType(TextField).first, + 'https://example.com/manual', + ); + await tester.tap(find.text('Apply')); + await tester.pumpAndSettle(); + expect(result?.href, 'https://example.com/manual'); + }, + ); + for (final heading in [ + (source: '# Café', id: 'café', label: 'Café'), + ( + source: '# Explicit {id="élément%20id"}', + id: 'élément%20id', + label: 'Explicit', + ), + ]) { + testWidgets('chooser → index → exact target navigation: ${heading.id}', ( + tester, + ) async { + final target = ParsedReferenceDocument( + ReferenceDocument( + identity: '/workspace/target.md', + path: '/workspace/target.md', + title: 'Target', + source: heading.source, + ), + ); + final chooserIndex = ReferenceIndex( + root: '/workspace', + parsed: [ + ParsedReferenceDocument( + const ReferenceDocument( + identity: '/workspace/source.md', + path: '/workspace/source.md', + title: 'Source', + source: 'source', + ), + ), + target, + ], + ); + Widget scoped( + Widget child, + ReferenceIndex value, { + bool navigate = false, + }) => ProviderScope( + overrides: [ + referenceIndexProvider.overrideWith(() => FixtureIndex(value)), + workspaceControllerProvider.overrideWith( + () => navigate + ? NavigableFixtureWorkspace(value) + : FixtureWorkspace(), + ), + refactoringRecoveryProvider.overrideWith((_) async => []), + ], + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold(body: child), + ), + ); + LinkDestination? result; + await tester.pumpWidget( + scoped( + Builder( + builder: (context) => TextButton( + onPressed: () async => + result = await showReferenceLinkChooser(context), + child: const Text('Choose'), + ), + ), + chooserIndex, + ), + ); + await tester.tap(find.text('Choose')); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextField).last, heading.label); + await tester.pumpAndSettle(); + final href = 'target.md#${Uri.encodeComponent(heading.id)}'; + await tester.tap(find.text(href)); + await tester.pumpAndSettle(); + expect(result!.href, href); + final source = ParsedReferenceDocument( + ReferenceDocument( + identity: '/workspace/source.md', + path: '/workspace/source.md', + title: 'Source', + source: '[chosen](${result!.href})', + ), + ); + final resolved = ReferenceIndex( + root: '/workspace', + parsed: [source, target], + ); + expect( + resolved.references.single.resolution, + ReferenceResolution.resolved, + ); + expect( + resolved + .query(target.document.identity, incoming: true, anchor: heading.id) + .total, + 1, + ); + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pumpWidget( + scoped(const ReferencesPanel(), resolved, navigate: true), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byType(PopupMenuButton)); + await tester.pumpAndSettle(); + await tester.tap(find.text('Target location')); + await tester.pumpAndSettle(); + final container = ProviderScope.containerOf( + tester.element(find.byType(ReferencesPanel)), + ); + final navigation = container.read(notesMatchNavigationProvider)!; + expect(navigation.localId, target.document.identity); + expect(navigation.start, target.headings.single.span.startOffset); + expect(navigation.end, target.headings.single.span.endOffset); + expect(navigation.digest, target.document.revision); + expect( + (container.read(workspaceControllerProvider.notifier) + as NavigableFixtureWorkspace) + .navigatedMode, + DocumentViewModePreference.source, + ); + }); + } +} diff --git a/test/src/references/writerside_reference_adapter_test.dart b/test/src/references/writerside_reference_adapter_test.dart new file mode 100644 index 00000000..0ff4bfeb --- /dev/null +++ b/test/src/references/writerside_reference_adapter_test.dart @@ -0,0 +1,127 @@ +import 'dart:io'; +import 'package:busymark/src/references/reference_index.dart'; +import 'package:busymark/src/references/markdown_references.dart'; +import 'package:busymark/src/references/writerside_reference_adapter.dart'; +import 'package:busymark/src/writerside/writerside_project.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test( + 'authored include origins, explicit IDs, ambiguity and instance availability share the existing resolver', + () async { + final root = await Directory.systemTemp.createTemp( + 'reference-writerside-', + ); + addTearDown(() => root.delete(recursive: true)); + Future write(String path, String source) async { + final file = File('${root.path}/$path'); + await file.parent.create(recursive: true); + await file.writeAsString(source); + } + + for (final module in ['main', 'shared']) { + await write( + '$module/writerside.cfg', + '', + ); + for (final instance in ['guide', 'beta']) { + await write( + '$module/$instance.tree', + '${module == 'main' ? '' : ''}', + ); + } + } + await write( + 'main/topics/source.topic', + '

VariableElementAmbiguous

', + ); + await write( + 'shared/topics/target.topic', + '

Target

Reusable link

', + ); + await write( + 'shared/topics/first/dup.topic', + '

A

', + ); + await write( + 'shared/topics/nested/dup.topic', + '

B

', + ); + final project = await const WritersideProjectService().load(root.path); + ReferenceIndex index(String instance) { + final topics = project.modules.expand((m) => m.topics).toList(); + final value = ReferenceIndex( + root: root.path, + parsed: [ + for (final topic in topics) + ParsedReferenceDocument( + ReferenceDocument( + identity: topic.filePath, + path: topic.filePath, + title: topic.title ?? topic.fileName, + source: topic.document.source, + ), + ), + ], + ownedPaths: topics.map((t) => t.filePath).toSet(), + ); + addWritersideReferences(value, project.index, instance); + return value; + } + + final guide = index('guide'); + final authored = guide.references + .where((r) => r.source.path!.endsWith('source.topic')) + .toList(); + final element = authored.singleWhere( + (r) => r.authored.destination.contains('#explicit'), + ); + expect(element.resolution, ReferenceResolution.resolved); + expect(element.target!.anchor, 'explicit'); + expect(element.target!.moduleId, 'shared'); + expect( + authored + .singleWhere((r) => r.authored.destination == '%target%') + .resolution, + ReferenceResolution.resolved, + ); + expect( + authored + .singleWhere((r) => r.authored.destination == 'dup.topic') + .resolution, + ReferenceResolution.ambiguous, + ); + final include = authored + .where((r) => r.authored.kind == AuthoredReferenceKind.include) + .toList(); + expect(include, isNotEmpty); + expect(include.map((r) => r.authored.origin), everyElement('shared')); + expect( + include.map((r) => r.resolution), + everyElement(ReferenceResolution.unavailable), + ); + expect( + index('beta').references + .where( + (r) => + r.source.path!.endsWith('source.topic') && + r.authored.kind == AuthoredReferenceKind.include, + ) + .map((r) => r.resolution), + everyElement(ReferenceResolution.resolved), + ); + // Reuse indexes its library occurrence once, not an expansion in the caller. + expect( + guide.references.where((r) => r.authored.destination == '#explicit'), + hasLength(1), + ); + expect( + guide.references + .singleWhere((r) => r.authored.destination == '#explicit') + .source + .path, + endsWith('target.topic'), + ); + }, + ); +} diff --git a/test/src/source_audit_test.dart b/test/src/source_audit_test.dart index a1dab3a6..844f0d88 100644 --- a/test/src/source_audit_test.dart +++ b/test/src/source_audit_test.dart @@ -200,7 +200,16 @@ void main() { ]) { expect(combined, isNot(contains(removed))); } - expect(RegExp(r'Ctrl\+P(?![A-Za-z])').hasMatch(combined), isFalse); + // Ctrl+P is now the approved Quick Open binding. Printing remains absent. + final shortcuts = File( + 'lib/src/app/busymark_shortcuts.dart', + ).readAsStringSync(); + expect(shortcuts, contains("quickOpenLabel = 'Ctrl+P'")); + expect(shortcuts, contains("commandPaletteLabel = 'Ctrl+Shift+P'")); + final registry = File( + 'lib/src/app/command_registry.dart', + ).readAsStringSync(); + expect(registry, contains('BusyMarkAppShortcutAction.quickOpen')); }); test('product stderr logging is isolated behind debug logging', () { @@ -1924,7 +1933,7 @@ void main() { expect(serializer, contains('String _listItem(')); expect(serializer, contains('String _indentBlock(')); expect(toolbar, isNot(contains('transparent: true'))); - expect(RegExp(r'transparent: false').allMatches(toolbar), hasLength(3)); + expect(RegExp(r'transparent: false').allMatches(toolbar), hasLength(5)); expect(toolbar, contains('BusyMarkHeaderIconButton(')); expect(toolbar, contains('_editorToolbarButtonBackground(context)')); expect(toolbar, contains('return theme.colorScheme.primary')); @@ -1932,13 +1941,13 @@ void main() { RegExp( r'foregroundColor: BusyMarkLinuxPalette\.white', ).allMatches(toolbar), - hasLength(3), + hasLength(5), ); expect(toolbar, isNot(contains('busyMarkContainedControlBackground('))); expect(toolbar, isNot(contains('foregroundColor: colors.foreground'))); - expect(RegExp(r'elevated: true').allMatches(toolbar), hasLength(3)); + expect(RegExp(r'elevated: true').allMatches(toolbar), hasLength(5)); expect(toolbar, isNot(contains('accented: true'))); - expect(toolbar, contains('clipBehavior: Clip.none')); + expect(toolbar, contains('clipBehavior: Clip.hardEdge')); expect(toolbar, contains('hitTestBehavior: HitTestBehavior.deferToChild')); expect(toolbar, contains('horizontal: BusyMarkSpacing.sm')); expect(toolbar, contains('vertical: BusyMarkSpacing.xs')); diff --git a/test/src/source_editor_widget_test.dart b/test/src/source_editor_widget_test.dart index 1bece9ed..5f9db8c8 100644 --- a/test/src/source_editor_widget_test.dart +++ b/test/src/source_editor_widget_test.dart @@ -1,3 +1,4 @@ +import 'package:busymark/src/editor/link_destination.dart'; import 'dart:async'; import 'dart:convert'; import 'dart:io'; @@ -51,6 +52,36 @@ import 'package:markdown/markdown.dart' as md; import 'package:yaru/yaru.dart'; void main() { + for (final stale in [false, true]) { + testWidgets( + 'Source target chooser ${stale ? 'rejects changed selection' : 'preserves selected text and formatting'}', + (tester) async { + final pending = Completer(); + final controller = await _pumpClipboardSourceEditor( + tester, + source: 'Chosen **format**', + clipboard: _SourceTestClipboard(), + onChooseLink: () => pending.future, + ); + controller.selection = const TextSelection( + baseOffset: 0, + extentOffset: 17, + ); + await _pressControlKey(tester, LogicalKeyboardKey.keyK); + await tester.pump(); + if (stale) { + controller.selection = const TextSelection.collapsed(offset: 0); + } + pending.complete(const LinkDestination('target.md#actual')); + await tester.pumpAndSettle(); + expect( + controller.text, + stale ? 'Chosen **format**' : '[Chosen **format**](target.md#actual)', + ); + }, + ); + } + testWidgets('Source pastes a web layout table as a complete document', ( tester, ) async { @@ -9351,6 +9382,7 @@ Future _pumpClipboardSourceEditor( AssetInputService? assetInputService, AssetIngestionService assetIngestionService = const AssetIngestionService(), VoidCallback? onAssetSaveRequired, + Future Function()? onChooseLink, }) async { await tester.pumpWidget( MaterialApp( @@ -9365,6 +9397,7 @@ Future _pumpClipboardSourceEditor( width: 900, height: 600, child: BusyMarkSourceEditor( + onChooseLink: onChooseLink, key: UniqueKey(), text: source, language: SourceSyntaxLanguage.markdown, diff --git a/test/src/workspace_controller_test.dart b/test/src/workspace_controller_test.dart index 40b23ceb..12dca0e2 100644 --- a/test/src/workspace_controller_test.dart +++ b/test/src/workspace_controller_test.dart @@ -3,6 +3,9 @@ import 'dart:io'; import 'package:busymark/src/core/path_utils.dart'; import 'package:busymark/src/writerside/writerside_project.dart'; +import 'package:busymark/src/editor/wysiwyg/writerside_editing_adapter.dart'; +import 'package:busymark/src/editor/wysiwyg/wysiwyg_document_controller.dart'; +import 'package:busymark/src/editor/wysiwyg/wysiwyg_commands.dart'; import 'package:busymark/src/markdown/markdown_parser.dart'; import 'package:busymark/src/markdown/markdown_model.dart'; @@ -40,6 +43,71 @@ import 'package:xml/xml.dart'; void main() { _registerResponsivenessTests(); _registerCorrectionBoundaryTests(); + _registerReferenceMoveTests(); + test( + 'XML visual edits use buffer history, save and reopen without Markdown conversion', + () async { + final root = await Directory.systemTemp.createTemp( + 'busymark-xml-authoring-buffer-', + ); + addTearDown(() => root.delete(recursive: true)); + final file = File(p.join(root.path, 't.topic')); + const original = + '\n

Text

\n'; + await file.writeAsString(original); + final harness = await _createControllerHarness(); + await harness.settingsController.setAutoSave(false); + await harness.settingsController.setValidateOnEdit(false); + final controller = harness.controller._notifier; + await controller.openPath(file.path); + final buffer = controller.state.activeBuffer!; + final visual = BusyMarkWysiwygDocumentController( + document: const WritersideEditingAdapter().parseXml( + filePath: file.path, + source: buffer.text, + )!, + ); + final paragraph = visual.document.blocks + .firstWhere((b) => b.attributes['element'] == 'topic') + .children + .firstWhere((b) => b.kind == BusyBlockKind.paragraph); + visual.insertWriterside( + BusyWritersideInsertCommand.tabs, + paragraph.id, + title: 'First', + ); + final authored = visual.markdown; + visual.rebaseCommittedSource(authored); + controller.updateActiveWysiwygText( + authored, + document: visual.document, + sourceBufferId: buffer.id, + sourceFilePath: file.path, + ); + expect(controller.state.activeBuffer!.dirty, isTrue); + expect( + controller.state.activeBuffer!.editorState.undoState.undo, + hasLength(1), + ); + expect(controller.undoActiveBuffer(), isTrue); + expect(controller.state.activeText, original); + expect(controller.state.activeBuffer!.dirty, isFalse); + expect(controller.redoActiveBuffer(), isTrue); + expect(controller.state.activeText, authored); + expect(await controller.saveActive(), isTrue); + expect(await file.readAsString(), authored); + expect(controller.state.activeBuffer!.dirty, isFalse); + final reopened = const WritersideEditingAdapter().parseXml( + filePath: file.path, + source: await file.readAsString(), + )!; + expect(reopened.isXmlTopic, isTrue); + expect(const WritersideEditingAdapter().serializeXml(reopened), authored); + expect(authored, contains('')); + expect(authored, contains('e:keep="yes"')); + expect(authored, contains('')); + }, + ); test( 'Welcome creation cannot overwrite an edit made during history flush', () async { @@ -6634,3 +6702,60 @@ class _RemovalNavigationWorkspaceService extends WorkspaceService { return super.prepareDocument(workspace, buffer, sources); } } + +void _registerReferenceMoveTests() { + test( + 'ordinary local rename refuses unreviewed links and dirty buffers and preserves tab identity after reviewed application', + () async { + final root = await Directory.systemTemp.createTemp( + 'busymark-reviewed-rename-', + ); + addTearDown(() => root.delete(recursive: true)); + final source = File(p.join(root.path, 'source.md')), + target = File(p.join(root.path, 'target.md')); + await source.writeAsString('[target](target.md)'); + await target.writeAsString('# Target'); + final harness = await _createControllerHarness( + localHistoryStore: FileLocalHistoryStore( + rootDirectory: () async => Directory(p.join(root.path, '.history')), + ), + ); + await harness.settingsController.setAutoSave(false); + final controller = harness.controller._notifier; + await controller.openPath(root.path); + await controller.openActiveFile(target.path); + final id = controller.state.activeBuffer!.id; + expect( + await controller.renameWorkspaceEntity(target.path, 'renamed.md'), + isFalse, + ); + expect(await source.readAsString(), '[target](target.md)'); + controller.updateActiveText('# Dirty'); + await expectLater( + controller.prepareMarkdownMove( + target.path, + p.join(root.path, 'renamed.md'), + ), + throwsA(anything), + ); + expect(await controller.saveActive(), isTrue); + final plan = await controller.prepareMarkdownMove( + target.path, + p.join(root.path, 'renamed.md'), + ); + expect( + await controller + .renameWorkspaceEntity( + target.path, + 'renamed.md', + reviewedPlan: plan, + ) + .timeout(const Duration(seconds: 10)), + isTrue, + ); + expect(controller.state.activeBuffer!.id, id); + expect(controller.state.activeBuffer!.text, '# Dirty'); + expect(await source.readAsString(), '[target](renamed.md)'); + }, + ); +} diff --git a/test/src/writerside_authoring_test.dart b/test/src/writerside_authoring_test.dart new file mode 100644 index 00000000..df5fdaf2 --- /dev/null +++ b/test/src/writerside_authoring_test.dart @@ -0,0 +1,1733 @@ +import 'dart:io'; + +import 'package:busymark/src/editor/wysiwyg/writerside_editing_adapter.dart'; +import 'package:busymark/src/editor/wysiwyg/wysiwyg_document_controller.dart'; +import 'package:busymark/src/editor/wysiwyg/wysiwyg_commands.dart'; +import 'package:busymark/src/editor/wysiwyg/wysiwyg_inline_controller.dart'; +import 'package:busymark/src/editor/wysiwyg/wysiwyg_clipboard_fragment.dart'; +import 'package:busymark/src/core/source_span.dart'; +import 'package:busymark/src/markdown/busymark_document.dart'; +import 'package:busymark/src/markdown/markdown_model.dart'; +import 'package:busymark/src/markdown/markdown_parser.dart'; +import 'package:busymark/src/writerside/writerside_document_parser.dart'; +import 'package:busymark/src/writerside/writerside_project.dart'; +import 'package:busymark/src/workspace/workspace_model.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:xml/xml.dart'; + +Iterable walk(Iterable blocks) sync* { + for (final b in blocks) { + yield b; + yield* walk(b.children); + } +} + +void main() { + const adapter = WritersideEditingAdapter(); + const xml = + '\n\n\nClick Save & continue.\n\nContent.Protected\n\n\n'; + test('visual capability is independent of AI capability', () { + expect(DocumentKind.writersideXmlTopic.supportsVisualEditing, isTrue); + expect(DocumentKind.writersideXmlTopic.supportsAiMarkdownEditing, isFalse); + expect(DocumentKind.config.supportsVisualEditing, isFalse); + }); + test( + 'Markdown variable references retain their editable type after reopening', + () { + const parser = MarkdownParser(); + final c = BusyMarkWysiwygDocumentController( + document: parser + .parse( + filePath: 'a.md', + source: '# A\n\nText\n', + mode: MarkdownMode.writersideMarkdown, + ) + .busyDocument, + ); + final p = walk( + c.document.blocks, + ).firstWhere((b) => b.kind == BusyBlockKind.paragraph); + c.insertVariableReference(p.id, 'product', 4, 4); + final source = c.markdown; + final reopened = parser + .parse( + filePath: 'a.md', + source: source, + mode: MarkdownMode.writersideMarkdown, + ) + .busyDocument; + final editor = BusyMarkWysiwygDocumentController(document: reopened); + final paragraph = walk( + editor.document.blocks, + ).firstWhere((b) => b.kind == BusyBlockKind.paragraph); + expect(paragraph.plainText, 'Textproduct'); + final reference = busyInlineReferenceRanges(paragraph.inlines).single; + expect(reference.kind, BusyInlineKind.writersideVariable); + expect(reference.attributes['reference'], 'product'); + expect(editor.markdown, source); + expect( + editor.updateInlineReference( + paragraph.id, + reference, + 'reference', + 'version', + ), + isTrue, + ); + expect(editor.markdown, contains('Text%version%')); + final ordinary = parser + .parse( + filePath: 'a.md', + source: source, + mode: MarkdownMode.commonMark, + ) + .busyDocument; + expect( + walk( + ordinary.blocks, + ).firstWhere((b) => b.kind == BusyBlockKind.paragraph).plainText, + 'Text%product%', + ); + }, + ); + test( + 'Markdown reference binding preserves formatting and literal contexts', + () { + const source = r'''# A + +%product% **%version%** [more %product%](guide.topic) \%product% %product% `%product%` $x%product%$ + +%product% +'''; + final document = const MarkdownParser() + .parse( + filePath: 'a.md', + source: source, + mode: MarkdownMode.writersideMarkdown, + ) + .busyDocument; + final c = BusyMarkWysiwygDocumentController(document: document); + final paragraph = walk( + c.document.blocks, + ).firstWhere((b) => b.kind == BusyBlockKind.paragraph); + expect( + busyInlineReferenceRanges(paragraph.inlines) + .where((r) => r.kind == BusyInlineKind.writersideVariable) + .map((r) => r.attributes['reference']), + ['product', 'version', 'product'], + ); + expect(c.markdown, source); + expect(walk(c.document.blocks).every((b) => !b.dirty), isTrue); + c.replaceDocument(document); + expect(c.markdown, source); + expect( + busyInlineReferenceRanges( + walk( + c.document.blocks, + ).firstWhere((b) => b.kind == BusyBlockKind.paragraph).inlines, + ).where((r) => r.kind == BusyInlineKind.writersideVariable), + hasLength(3), + ); + }, + ); + test( + 'Markdown switcher labels preserve literal metadata through reopening', + () { + for (final entry in { + 'Operating System': 'Operating System', + 'النظام': 'النظام', + 'Platform: Choice': 'Platform: Choice', + r'Platform: "Desktop" \ Keys': r'Platform: "Desktop" \ Keys', + "Author's platform": "Author's platform", + 'true': 'true', + 'Leading\nline': r'"Leading\nline"', + ' padded ': '" padded "', + }.entries) { + final c = BusyMarkWysiwygDocumentController( + document: const MarkdownParser() + .parse( + filePath: 'a.md', + source: '# A\n\nText\n', + mode: MarkdownMode.writersideMarkdown, + ) + .busyDocument, + ); + expect(c.updateTopicSwitcherLabel(entry.key), isTrue); + expect( + c.markdown, + startsWith('---\nswitcher-label: ${entry.value}\n---\n'), + ); + expect(c.document.frontMatter['switcher-label'], entry.key); + expect(c.markdown, contains('# A')); + final reopened = const MarkdownParser() + .parse( + filePath: 'a.md', + source: c.markdown, + mode: MarkdownMode.writersideMarkdown, + ) + .busyDocument; + expect(reopened.frontMatter['switcher-label'], entry.key); + expect( + BusyMarkWysiwygDocumentController(document: reopened).markdown, + c.markdown, + ); + } + }, + ); + test( + 'existing double-quoted switcher labels decode without changing source', + () { + const source = r'''--- +switcher-label: "Platform: \"Desktop\" \\ Keys" +custom: "kept\\raw" +--- + +# A + +Text +'''; + final parser = const MarkdownParser(); + final document = parser + .parse( + filePath: 'a.md', + source: source, + mode: MarkdownMode.writersideMarkdown, + ) + .busyDocument; + expect( + document.frontMatter['switcher-label'], + r'Platform: "Desktop" \ Keys', + ); + expect(document.frontMatter['custom'], r'kept\\raw'); + final c = BusyMarkWysiwygDocumentController(document: document); + expect(c.markdown, source); + expect(c.updateTopicSwitcherLabel("Author's: \"Desktop\""), isTrue); + expect(c.markdown, contains("switcher-label: Author's: \"Desktop\"")); + expect(c.markdown, contains(r'custom: "kept\\raw"')); + final ordinary = parser + .parse( + filePath: 'a.md', + source: source, + mode: MarkdownMode.commonMark, + ) + .busyDocument; + expect( + ordinary.frontMatter['switcher-label'], + r'Platform: \"Desktop\" \\ Keys', + ); + }, + ); + test( + 'clearing the topic switcher label restores the default without changing other metadata', + () { + final c = BusyMarkWysiwygDocumentController( + document: const MarkdownParser() + .parse( + filePath: 'a.md', + source: '---\ncustom: kept\n---\n\n# A\n\nText\n', + mode: MarkdownMode.writersideMarkdown, + ) + .busyDocument, + ); + final original = c.markdown; + expect(c.updateTopicSwitcherLabel(''), isFalse); + expect(c.markdown, original); + expect(c.updateTopicSwitcherLabel("Author's: Platform"), isTrue); + expect(c.updateTopicSwitcherLabel(''), isTrue); + expect(c.markdown, original); + expect(c.document.frontMatter.containsKey('switcher-label'), isFalse); + }, + ); + test('XML no-edit round trip preserves all source exactly', () { + final c = BusyMarkWysiwygDocumentController( + document: adapter.parseXml(filePath: 'demo.topic', source: xml)!, + ); + expect(c.markdown, xml); + }); + test( + 'XML changed leaves preserve qualified names and rebase for repeated edits', + () { + final c = BusyMarkWysiwygDocumentController( + document: adapter.parseXml(filePath: 'demo.topic', source: xml)!, + ); + final p = walk( + c.document.blocks, + ).firstWhere((b) => b.attributes['id'] == 'intro'); + c.updateBlockText(p.id, 'Click Save now & continue.'); + final first = c.markdown; + expect( + first, + contains( + '', + ), + ); + expect( + first, + contains('Protected'), + ); + expect(first, contains('')); + c.rebaseCommittedSource(first); + expect( + c.blockById(p.id)!.sourceSpan!.endOffset, + greaterThan(p.sourceSpan!.endOffset), + ); + c.updateBlockText(p.id, 'Click Save now & continue again.'); + final second = c.markdown; + expect(second, contains('Save')); + expect( + second, + contains(''), + ); + expect( + const WritersideDocumentParser() + .parseXml(filePath: 'demo.topic', source: second) + .isWellFormed, + isTrue, + ); + }, + ); + test('XML title edits update root attribute', () { + final c = BusyMarkWysiwygDocumentController( + document: adapter.parseXml(filePath: 'demo.topic', source: xml)!, + ); + final root = c.document.blocks.firstWhere( + (b) => b.attributes['element'] == 'topic', + ); + c.updateBlockText(root.id, 'New title'); + expect(c.markdown, contains('title="New title"')); + expect(c.markdown, startsWith('Protected'), + ); + }); + test('XML insertion and formatting inherit the authored namespace', () { + final c = BusyMarkWysiwygDocumentController( + document: adapter.parseXml( + filePath: 't.topic', + source: + 'Text', + )!, + ); + final p = walk( + c.document.blocks, + ).firstWhere((b) => b.kind == BusyBlockKind.paragraph); + c.applyInlineCommand(p.id, BusyWysiwygInlineCommand.uiControl, 0, 4); + c.insertWriterside( + BusyWritersideInsertCommand.procedure, + p.id, + title: 'Procedure', + ); + final source = c.markdown; + expect(source, contains('Text')); + expect(source, contains('')); + expect(source, contains('xmlns:ws="urn:topic"')); + c.rebaseCommittedSource(source); + c.updateBlockText(p.id, 'Texxt'); + expect(c.markdown, contains('Texxt')); + }); + for (final context in [ + ( + name: 'different namespace', + prefix: 'nested:', + declaration: 'xmlns:nested="urn:nested"', + uri: 'urn:nested', + ), + ( + name: 'namespace alias', + prefix: 'nested:', + declaration: 'xmlns:nested="urn:topic"', + uri: 'urn:topic', + ), + ( + name: 'rebound root prefix', + prefix: 'nested:', + declaration: 'xmlns:nested="urn:nested" xmlns:ws="urn:rebound"', + uri: 'urn:nested', + ), + ( + name: 'locally rebound parent prefix', + prefix: 'ws:', + declaration: 'xmlns:ws="urn:nested"', + uri: 'urn:nested', + ), + ( + name: 'default namespace', + prefix: '', + declaration: 'xmlns="urn:nested"', + uri: 'urn:nested', + ), + ]) { + test('XML editing inherits the parent ${context.name}', () { + final prefix = context.prefix; + final source = + '\n\n' + '' + '<${prefix}chapter ${context.declaration} ext:mode="kept" title="Chapter" id="nested">' + '<${prefix}p id="text">Text & more' + 'Protected' + ''; + final c = BusyMarkWysiwygDocumentController( + document: adapter.parseXml(filePath: 't.topic', source: source)!, + ); + expect(c.markdown, source); + final paragraph = walk( + c.document.blocks, + ).firstWhere((b) => b.attributes['id'] == 'text'); + final chapter = walk( + c.document.blocks, + ).firstWhere((b) => b.attributes['id'] == 'nested'); + c.applyInlineCommand( + paragraph.id, + BusyWysiwygInlineCommand.uiControl, + 0, + 4, + ); + final procedure = c.insertWriterside( + BusyWritersideInsertCommand.procedure, + paragraph.id, + title: 'Procedure', + )!; + final tabs = c.insertWriterside( + BusyWritersideInsertCommand.tabs, + paragraph.id, + title: 'Tab', + )!; + for (final id in [procedure, tabs]) { + final leaf = walk( + c.blockById(id)!.children, + ).firstWhere((b) => b.kind == BusyBlockKind.paragraph); + c.updateBlockText(leaf.id, 'Inserted content'); + } + c.updateBlockText(chapter.id, 'Edited chapter'); + void verify(String saved) { + expect( + const WritersideDocumentParser() + .parseXml(filePath: 't.topic', source: saved) + .isWellFormed, + isTrue, + ); + final xmlDocument = XmlDocument.parse(saved); + for (final element in xmlDocument.descendants.whereType()) { + if (element.name.prefix != null) { + expect(element.namespaceUri, isNotNull); + } + for (final attribute in element.attributes) { + if (attribute.name.prefix != null && + attribute.name.prefix != 'xmlns') { + expect(attribute.namespaceUri, isNotNull); + } + } + } + final parent = xmlDocument.descendants + .whereType() + .firstWhere((e) => e.getAttribute('id') == 'nested'); + expect(parent.name.qualified, '${prefix}chapter'); + expect(parent.getAttribute('title'), 'Edited chapter'); + expect( + parent.attributes + .firstWhere((a) => a.name.qualified == 'ext:mode') + .namespaceUri, + 'urn:extension', + ); + for (final element in parent.descendants.whereType().where( + (e) => e.name.prefix != 'ext', + )) { + expect( + element.namespaceUri, + context.uri, + reason: element.toXmlString(), + ); + expect(element.name.qualified, '$prefix${element.name.local}'); + } + expect( + parent.descendants.whereType().map((e) => e.name.local), + containsAll(['procedure', 'step', 'tabs', 'tab', 'control']), + ); + expect(saved, startsWith('\n\n')); + expect( + saved, + contains( + 'Protected', + ), + ); + expect( + RegExp(r'xmlns[:=]').allMatches(saved).length, + RegExp(r'xmlns[:=]').allMatches(source).length, + ); + } + + final saved = c.markdown; + verify(saved); + c.rebaseCommittedSource(saved); + expect(c.blockById(paragraph.id), isNotNull); + c.updateBlockText(paragraph.id, 'Texxt & more'); + verify(c.markdown); + final reopened = BusyMarkWysiwygDocumentController( + document: adapter.parseXml(filePath: 't.topic', source: c.markdown)!, + ); + final p = walk( + reopened.document.blocks, + ).firstWhere((b) => b.attributes['id'] == 'text'); + reopened.applyInlineCommand( + p.id, + BusyWysiwygInlineCommand.filePath, + 8, + 12, + ); + verify(reopened.markdown); + }); + } + test('XML formatting inherits declarations on existing inline elements', () { + const source = + '' + 'Save'; + final c = BusyMarkWysiwygDocumentController( + document: adapter.parseXml(filePath: 't.topic', source: source)!, + ); + expect(c.markdown, source); + final p = walk( + c.document.blocks, + ).firstWhere((b) => b.kind == BusyBlockKind.paragraph); + c.applyInlineCommand(p.id, BusyWysiwygInlineCommand.uiControl, 0, 4); + final saved = c.markdown; + final elements = XmlDocument.parse( + saved, + ).descendants.whereType().toList(); + final control = elements.firstWhere((e) => e.name.local == 'control'); + expect(control.name.qualified, 'n:control'); + final bold = elements.firstWhere((e) => e.name.local == 'b'); + expect(bold.name.qualified, 'n:b'); + expect( + bold.attributes + .firstWhere((a) => a.name.qualified == 'n:role') + .namespaceUri, + 'urn:inline', + ); + expect(control.namespaceUri, 'urn:inline'); + c.rebaseCommittedSource(saved); + c.updateBlockText(p.id, 'Saved'); + c.applyInlineCommand(p.id, BusyWysiwygInlineCommand.filePath, 0, 5); + final edited = XmlDocument.parse(c.markdown).descendants + .whereType() + .where((e) => {'b', 'control', 'path'}.contains(e.name.local)) + .toList(); + expect( + edited.map((e) => e.name.local), + containsAll(['b', 'control', 'path']), + ); + for (final element in edited) { + expect(element.namespaceUri, 'urn:inline'); + expect(element.name.prefix, 'n'); + } + }); + test( + 'XML ordinary inline and image edits serialize as XML, unsupported insertions are inert', + () { + final c = BusyMarkWysiwygDocumentController( + document: adapter.parseXml( + filePath: 't.topic', + source: + '

Text

Old
', + )!, + ); + final p = walk( + c.document.blocks, + ).firstWhere((b) => b.kind == BusyBlockKind.paragraph); + c.applyInlineCommand(p.id, BusyWysiwygInlineCommand.underline, 0, 4); + c.applyInlineCommand(p.id, BusyWysiwygInlineCommand.strikethrough, 0, 4); + c.insertHardBreak(p.id, 4); + final source = c.markdown; + expect(source, contains('')); + expect(source, contains('')); + expect(source, contains('
')); + c.rebaseCommittedSource(source); + expect(c.blockById(p.id)!.preserveRaw, isFalse); + final img = walk( + c.document.blocks, + ).firstWhere((b) => b.kind == BusyBlockKind.image); + c.applyImageBlock(img.id, source: 'new.png', alt: 'New'); + expect( + c.markdown, + contains('New'), + ); + final before = c.markdown; + expect( + c.insertTableAfter( + p.id, + columns: 2, + rows: 2, + headerTextForColumn: (n) => '$n', + cellText: 'Cell', + ), + isNull, + ); + expect(c.insertDisplayMathAfter(p.id), isNull); + expect(c.insertRawHtmlBlockAfter(p.id, '
HTML
'), isNull); + c.applyInlineCommand( + c.document.blocks.first.id, + BusyWysiwygInlineCommand.uiControl, + 0, + 4, + ); + expect(c.markdown, before); + }, + ); + test('malformed XML has no writable visual projection', () { + expect( + adapter.parseXml(filePath: 'bad.topic', source: '

Oops'), + isNull, + ); + }); + test('XML text navigation binds titles, entities, repeated text and CDATA', () { + const source = + '

Save Save & %product%

'; + final document = adapter.parseXml(filePath: 't.topic', source: source)!; + final blocks = walk(document.blocks).toList(); + final title = blocks.firstWhere((b) => b.attributes['element'] == 'topic'); + expect( + adapter.xmlTextSourceOffset(document, title, 2, endBoundary: false), + source.indexOf('&'), + ); + final paragraph = blocks.firstWhere( + (b) => b.kind == BusyBlockKind.paragraph, + ); + expect( + adapter.xmlTextSourceOffset(document, paragraph, 5, endBoundary: false), + source.indexOf('') + 9, + ); + expect( + adapter.xmlTextSourceOffset(document, paragraph, 12, endBoundary: false), + source.indexOf('%product%') + 1, + ); + final code = blocks.firstWhere((b) => b.kind == BusyBlockKind.codeBlock); + expect( + adapter.xmlTextSourceOffset(document, code, 2, endBoundary: false), + source.indexOf('a < b') + 2, + ); + }); + test('Markdown structured descendants bind to the full source file', () { + const source = + '# Title\n\nBefore\n\n

Nested

\n'; + final document = const MarkdownParser() + .parse( + filePath: 'scope.md', + source: source, + mode: MarkdownMode.writersideMarkdown, + validateLocalReferences: false, + ) + .busyDocument; + final nested = walk( + document.blocks, + ).firstWhere((b) => b.plainText == 'Nested'); + expect(nested.sourceSpan!.filePath, 'scope.md'); + expect(nested.sourceSpan!.startOffset, source.indexOf('

')); + expect( + source.substring( + nested.sourceSpan!.startOffset, + nested.sourceSpan!.endOffset, + ), + '

Nested

', + ); + }); + test('variable selection follows live XML lexical parents and module', () { + const source = + '

Here

Other

'; + final c = BusyMarkWysiwygDocumentController( + document: adapter.parseXml(filePath: 't.topic', source: source)!, + ); + final index = WritersideProjectIndex( + symbols: [ + const WritersideSymbol( + name: 'global', + qualifiedName: 'm:global', + kind: WritersideSymbolKind.variable, + moduleId: 'm', + filePath: 'v.list', + ), + const WritersideSymbol( + name: 'wrong', + qualifiedName: 'other:wrong', + kind: WritersideSymbolKind.variable, + moduleId: 'other', + filePath: 'v.list', + ), + WritersideSymbol( + name: 'stale-local', + qualifiedName: 'm:stale-local', + kind: WritersideSymbolKind.variable, + moduleId: 'm', + filePath: 't.topic', + scopeSpan: SourceSpan.entireFile('t.topic', source), + ), + ], + references: [], + diagnostics: [], + ); + final paragraph = walk( + c.document.blocks, + ).firstWhere((b) => b.plainText == 'Here'); + expect(c.availableVariableReferences(index, 'm', paragraph.id), [ + 'global', + 'local', + 'root', + ]); + c.updateBlockText( + paragraph.id, + 'A longer paragraph shifts all later spans', + ); + c.rebaseCommittedSource(c.markdown); + expect(c.availableVariableReferences(index, 'm', paragraph.id), [ + 'global', + 'local', + 'root', + ]); + final other = walk( + c.document.blocks, + ).firstWhere((b) => b.plainText == 'Other'); + expect(c.availableVariableReferences(index, 'm', other.id), [ + 'global', + 'root', + 'sibling', + ]); + }); + test('topic H1 cannot acquire chapter collapse properties', () { + final c = BusyMarkWysiwygDocumentController( + document: const MarkdownParser() + .parse( + filePath: 't.md', + source: '# Title\n\nText\n', + mode: MarkdownMode.writersideMarkdown, + validateLocalReferences: false, + ) + .busyDocument, + ); + final before = c.markdown; + expect( + c.updateWritersideProperty( + c.document.blocks.first.id, + 'collapsible', + 'true', + ), + isFalse, + ); + expect(c.markdown, before); + }); + test( + 'XML clipboard retains copied structure identity and rejects Markdown-only payloads', + () { + final c = BusyMarkWysiwygDocumentController( + document: adapter.parseXml( + filePath: 't.topic', + source: + '

Original

Protected

Target

', + )!, + ); + final tabs = walk( + c.document.blocks, + ).firstWhere((b) => b.attributes['element'] == 'tabs'); + final target = walk( + c.document.blocks, + ).firstWhere((b) => b.plainText == 'Target'); + final pasted = c.insertStyledBlocksAtSelection( + blockId: target.id, + selectionStart: 0, + selectionEnd: 6, + blocks: [ + BusyWysiwygStyledBlock( + kind: tabs.kind, + text: '', + ranges: const [], + completeBlock: tabs, + ), + ], + ); + expect(pasted, isNotNull); + c.rebaseCommittedSource(c.markdown); + final copies = walk( + c.document.blocks, + ).where((b) => b.plainText == 'Original').toList(); + expect(copies, hasLength(2)); + expect(copies[0].id, isNot(copies[1].id)); + c.updateBlockText(copies[1].id, 'Copied edit'); + c.rebaseCommittedSource(c.markdown); + expect(c.blockById(copies[0].id)!.plainText, 'Original'); + expect(c.blockById(copies[1].id)!.plainText, 'Copied edit'); + expect(RegExp('').allMatches(c.markdown), hasLength(2)); + expect(RegExp('').allMatches(c.markdown), hasLength(2)); + final before = c.markdown; + expect( + c.insertStyledBlocksAtSelection( + blockId: copies[0].id, + selectionStart: 0, + selectionEnd: 0, + blocks: [ + const BusyWysiwygStyledBlock( + kind: BusyBlockKind.table, + text: 'Cell', + ranges: [], + ), + ], + ), + isNull, + ); + expect(c.markdown, before); + }, + ); + test('Markdown variable selection follows authored topic and tab scope', () { + const source = + '# Title\n\n\n\nText\n\n

Here

Other

\n'; + final c = BusyMarkWysiwygDocumentController( + document: const MarkdownParser() + .parse( + filePath: 't.md', + source: source, + mode: MarkdownMode.writersideMarkdown, + validateLocalReferences: false, + ) + .busyDocument, + ); + const index = WritersideProjectIndex( + symbols: [ + WritersideSymbol( + name: 'global', + qualifiedName: 'm:global', + kind: WritersideSymbolKind.variable, + moduleId: 'm', + filePath: 'v.list', + ), + ], + references: [], + diagnostics: [], + ); + final p = walk(c.document.blocks).firstWhere((b) => b.plainText == 'Here'); + expect(c.availableVariableReferences(index, 'm', p.id), [ + 'global', + 'local', + 'root', + ]); + final other = walk( + c.document.blocks, + ).firstWhere((b) => b.plainText == 'Other'); + expect(c.availableVariableReferences(index, 'm', other.id), [ + 'global', + 'root', + 'sibling', + ]); + }); + for (final xmlFormat in [false, true]) { + test( + '${xmlFormat ? 'XML' : 'Markdown'} semantic clipboard keeps mixed formatting, links and reference attributes', + () { + const fragment = + 'Save now file'; + final document = xmlFormat + ? adapter.parseXml( + filePath: 't.topic', + source: + '

$fragment

Target

', + )! + : const MarkdownParser() + .parse( + filePath: 't.md', + source: '# Title\n\n$fragment\n\nTarget\n', + mode: MarkdownMode.writersideMarkdown, + validateLocalReferences: false, + ) + .busyDocument; + final c = BusyMarkWysiwygDocumentController(document: document); + final paragraph = walk(c.document.blocks).firstWhere( + (b) => + b.kind == BusyBlockKind.paragraph && b.plainText.contains('Save'), + ); + final copied = WysiwygClipboardFragment( + blocks: [ + BusyWysiwygStyledBlock( + kind: BusyBlockKind.paragraph, + text: paragraph.plainText, + ranges: const [], + completeBlock: paragraph.copyWith( + inlines: busyMarkWysiwygClipboardInlineSlice( + paragraph.inlines, + 0, + paragraph.plainText.length, + ), + dirty: true, + ), + ), + ], + mode: MarkdownMode.writersideMarkdown, + ); + final decoded = WysiwygClipboardFragment.decode(copied.encode())!; + final target = walk( + c.document.blocks, + ).firstWhere((b) => b.plainText == 'Target'); + c.insertStyledBlocksAtSelection( + blockId: target.id, + selectionStart: 0, + selectionEnd: 6, + blocks: decoded.blocks, + ); + c.updateBlockText(target.id, 'Save soon file'); + final saved = c.markdown; + expect(saved, contains('')); + expect(saved, contains('key="\$Copy"')); + expect(saved, contains('from-keymap-of="IntelliJ IDEA"')); + expect(saved, contains('')); + expect(saved, contains('other.topic')); + }, + ); + } + test( + 'changed XML preserves colliding namespaced attributes and lexical inline source', + () { + const source = + '''

A Save & close Z

'''; + final c = BusyMarkWysiwygDocumentController( + document: adapter.parseXml(filePath: 'a.topic', source: source)!, + ); + final p = walk( + c.document.blocks, + ).firstWhere((b) => b.attributes['id'] == 'leaf'); + c.updateBlockText(p.id, 'A Save & close Z!'); + final saved = c.markdown; + expect(saved, contains('title=\'A > B\'')); + expect(saved, contains('id="root" e:id="external"')); + expect( + saved, + contains('Save & close'), + ); + expect(saved, contains('')); + c.rebaseCommittedSource(saved); + expect(c.blockById(p.id)!.attributes['e:id'], 'other'); + c.updateWritersideProperty(p.id, 'id', 'new'); + expect(c.markdown, contains('id="new" e:id="other"')); + }, + ); + test( + 'authored style and attributes that share projection names survive real edits', + () { + const source = + '

Definition

Alt
'; + final c = BusyMarkWysiwygDocumentController( + document: adapter.parseXml(filePath: 'a.topic', source: source)!, + ); + final list = walk( + c.document.blocks, + ).firstWhere((b) => b.attributes['element'] == 'deflist'); + expect(c.updateWritersideProperty(list.id, 'type', 'medium'), isTrue); + final saved = c.markdown; + expect(saved, contains('style="compact"')); + expect(saved, contains('level="extension"')); + c.rebaseCommittedSource(saved); + final image = walk( + c.document.blocks, + ).firstWhere((b) => b.kind == BusyBlockKind.image); + c.updateBlockText(image.id, 'Changed'); + expect(c.markdown, contains('style="inline"')); + final md = BusyMarkWysiwygDocumentController( + document: const MarkdownParser() + .parse( + filePath: 'a.md', + source: + '# Title\n\n

Definition

\n', + mode: MarkdownMode.writersideMarkdown, + ) + .busyDocument, + ); + final mdList = walk( + md.document.blocks, + ).firstWhere((b) => b.attributes['element'] == 'deflist'); + md.updateWritersideProperty(mdList.id, 'type', 'medium'); + expect(md.markdown, contains('style="compact"')); + }, + ); + test( + 'reference and media property edits keep authored leaf comments and children', + () { + const source = + 'Alt'; + final c = BusyMarkWysiwygDocumentController( + document: adapter.parseXml(filePath: 'a.topic', source: source)!, + ); + for (final (tag, name, value) in [ + ('video', 'src', 'after.mp4'), + ('img', 'id', 'image'), + ('show-structure', 'depth', '2'), + ('include', 'use-filter', 'true'), + ]) { + final block = walk( + c.document.blocks, + ).firstWhere((b) => b.attributes['element'] == tag); + expect( + c.updateWritersideProperty(block.id, name, value), + isTrue, + reason: '$tag.$name', + ); + } + final saved = c.markdown; + for (final tag in ['video', 'image', 'navigation', 'include']) { + expect(saved, contains('')); + } + expect(saved, contains('')); + c.rebaseCommittedSource(saved); + final video = walk( + c.document.blocks, + ).firstWhere((b) => b.attributes['element'] == 'video'); + c.updateWritersideProperty(video.id, 'src', 'final.mp4'); + expect(c.markdown, contains('')); + }, + ); + test( + 'Markdown reference property edits preserve authored arguments, comments and qualified names', + () { + const source = + '# Title\n\n\n\n\n'; + final c = BusyMarkWysiwygDocumentController( + document: const MarkdownParser() + .parse( + filePath: 'a.md', + source: source, + mode: MarkdownMode.writersideMarkdown, + ) + .busyDocument, + ); + final reference = walk( + c.document.blocks, + ).firstWhere((b) => b.attributes['element'] == 'include'); + expect( + c.updateWritersideProperty(reference.id, 'from', 'other.topic'), + isTrue, + ); + final video = walk( + c.document.blocks, + ).firstWhere((b) => b.attributes['element'] == 'video'); + expect(c.updateWritersideProperty(video.id, 'src', 'after.mp4'), isTrue); + final saved = c.markdown; + expect(saved, contains('')); + expect( + saved, + contains(''), + ); + expect(saved, contains('')); + expect( + const MarkdownParser() + .parse( + filePath: 'a.md', + source: saved, + mode: MarkdownMode.writersideMarkdown, + ) + .busyDocument + .mode, + MarkdownMode.writersideMarkdown, + ); + }, + ); + test('an unknown inline protects its paragraph rather than the topic', () { + final c = BusyMarkWysiwygDocumentController( + document: adapter.parseXml( + filePath: 'a.topic', + source: + '

Protected special

Editable

', + )!, + ); + final ps = walk( + c.document.blocks, + ).where((b) => b.kind == BusyBlockKind.paragraph).toList(); + expect(ps.first.preserveRaw, isTrue); + expect(ps.last.preserveRaw, isFalse); + c.updateBlockText(ps.last.id, 'Changed'); + expect( + c.markdown, + contains('

Protected special

Changed

'), + ); + }); + test( + 'empty shortcut references retain their attributes through surrounding edits', + () { + final c = BusyMarkWysiwygDocumentController( + document: adapter.parseXml( + filePath: 'a.topic', + source: + '

Copy here.

', + )!, + ); + final p = walk( + c.document.blocks, + ).firstWhere((b) => b.kind == BusyBlockKind.paragraph); + c.updateBlockText(p.id, 'Copy here again.'); + expect( + c.markdown, + contains( + '', + ), + ); + final reference = busyInlineReferenceRanges( + c.blockById(p.id)!.inlines, + ).firstWhere((r) => r.kind == BusyInlineKind.writersideShortcut); + expect( + c.updateInlineReference(p.id, reference, 'key', '\$Paste'), + isTrue, + ); + expect(c.markdown, contains('key="\$Paste"')); + expect( + c.markdown, + contains('from-keymap-of="IJ" force-layout="Windows"'), + ); + expect( + c.updateInlineReference(p.id, reference, 'key', '\$Delete'), + isFalse, + ); + final updated = busyInlineReferenceRanges( + c.blockById(p.id)!.inlines, + ).firstWhere((r) => r.kind == BusyInlineKind.writersideShortcut); + expect(c.updateInlineReference(p.id, updated, 'key', ''), isFalse); + expect( + c.updateInlineReference(p.id, updated, 'force-layout', ''), + isTrue, + ); + expect(c.markdown, contains('key="\$Paste" from-keymap-of="IJ"')); + expect(c.markdown, isNot(contains('force-layout='))); + }, + ); + test('Markdown definition form retains layout and definition content', () { + const source = + '{type="medium" collapsible="true" sorted="true"}\nFirst term\n: First **definition**.\n\nSecond term\n: Second definition.\n'; + final doc = const MarkdownParser() + .parse( + filePath: 'a.md', + source: source, + mode: MarkdownMode.writersideMarkdown, + validateLocalReferences: false, + ) + .busyDocument; + final c = BusyMarkWysiwygDocumentController(document: doc); + final list = walk( + doc.blocks, + ).firstWhere((b) => b.attributes['element'] == 'deflist'); + expect(list.attributes['type'], 'medium'); + expect(list.preserveRaw, isFalse); + final first = list.children.first; + c.updateBlockText(first.children.first.id, 'Changed definition.'); + expect( + c.markdown, + contains('{type="medium" collapsible="true" sorted="true"}'), + ); + expect(c.markdown, contains('First term\n: Changed **definition**.')); + c.updateWritersideProperty(first.id, 'default-state', 'expanded'); + final saved = c.markdown; + expect( + saved, + contains(''), + ); + expect(saved, contains('default-state="expanded"')); + expect(saved, contains('Second definition.')); + expect( + walk( + const MarkdownParser() + .parse( + filePath: 'a.md', + source: saved, + mode: MarkdownMode.writersideMarkdown, + ) + .busyDocument + .blocks, + ).where((b) => b.attributes['element'] == 'def'), + hasLength(2), + ); + }); + test( + 'XML list conversion preserves hierarchy and ordered versus choice semantics', + () { + for (final type in ['decimal', 'bullet']) { + final c = BusyMarkWysiwygDocumentController( + document: adapter.parseXml( + filePath: 'a.topic', + source: + '
  • First item

  • Nested
  • Second

  • ', + )!, + ); + final p = walk( + c.document.blocks, + ).firstWhere((b) => b.kind == BusyBlockKind.paragraph); + expect( + c.canInsertWriterside( + BusyWritersideInsertCommand.convertListToProcedure, + p.id, + ), + isTrue, + ); + final id = c.convertListToProcedure(p.id, title: 'Procedure')!; + expect(c.blockById(id)!.children, hasLength(2)); + final saved = c.markdown; + expect( + saved, + contains('type="${type == 'decimal' ? 'steps' : 'choices'}"'), + ); + expect(saved, contains('item')); + expect(saved, contains('
  • Nested
  • ')); + c.rebaseCommittedSource(saved); + expect(c.manageWritersideItem(id, title: 'Step'), isTrue); + expect( + c + .blockById(id)! + .children + .where((b) => b.attributes['element'] == 'step'), + hasLength(3), + ); + } + }, + ); + test( + 'list conversion retains comments and step identities, and refuses lossy attributes', + () { + final c = BusyMarkWysiwygDocumentController( + document: adapter.parseXml( + filePath: 'a.topic', + source: + '\n
  • First

  • Second

  • \n
    ', + )!, + ); + final first = walk( + c.document.blocks, + ).firstWhere((b) => b.plainText == 'First'); + expect(c.convertListToProcedure(first.id, title: 'Procedure'), isNotNull); + final saved = c.markdown; + expect( + saved, + contains(''), + ); + expect(saved, contains('')); + expect(saved, contains('id="list"')); + for (final attributes in ['columns="2"', 'type="bullet"']) { + final source = + '
  • Checked

  • '; + final protected = BusyMarkWysiwygDocumentController( + document: adapter.parseXml(filePath: 'a.topic', source: source)!, + ); + final p = walk( + protected.document.blocks, + ).firstWhere((b) => b.kind == BusyBlockKind.paragraph); + expect( + protected.canInsertWriterside( + BusyWritersideInsertCommand.convertListToProcedure, + p.id, + ), + isFalse, + ); + expect( + protected.convertListToProcedure(p.id, title: 'Procedure'), + isNull, + ); + expect(protected.markdown, source); + } + }, + ); + test('XML paragraph list commands create an actual list wrapper', () { + final c = BusyMarkWysiwygDocumentController( + document: adapter.parseXml( + filePath: 'a.topic', + source: '

    Content

    ', + )!, + ); + final p = walk( + c.document.blocks, + ).firstWhere((b) => b.kind == BusyBlockKind.paragraph); + c.applyBlockCommand(p.id, BusyWysiwygBlockCommand.orderedList); + expect( + c.markdown, + contains('
  • Content

  • '), + ); + expect( + c.canApplyBlockCommand( + c.blockById(p.id)!, + BusyWysiwygBlockCommand.thematicBreak, + ), + isFalse, + ); + }); + for (final xmlFormat in [false, true]) { + test( + '${xmlFormat ? 'XML' : 'Markdown'} manages tabs and definitions without losing nested content', + () { + final c = BusyMarkWysiwygDocumentController( + document: xmlFormat + ? adapter.parseXml( + filePath: 'a.topic', + source: + '

    Keep

    Two

    Meaning

    ', + )! + : const MarkdownParser() + .parse( + filePath: 'a.md', + source: + '# A\n\n\n\n\nKeep\n\n```dart\ncode\n```\n\n\n\nTwo\n\n\n\n

    Meaning

    \n', + mode: MarkdownMode.writersideMarkdown, + ) + .busyDocument, + ); + final tabs = walk( + c.document.blocks, + ).firstWhere((b) => b.attributes['element'] == 'tabs'); + final first = tabs.children.firstWhere( + (b) => b.attributes['element'] == 'tab', + ); + expect( + c.manageWritersideItem( + tabs.id, + itemId: first.id, + direction: 1, + title: 'Tab', + ), + isTrue, + ); + expect( + c.updateWritersideProperty(first.id, 'title', 'Renamed'), + isTrue, + ); + expect( + c.updateWritersideProperty(tabs.id, 'group', 'new-sync'), + isTrue, + ); + expect(c.manageWritersideItem(tabs.id, title: 'Third'), isTrue); + final list = walk( + c.document.blocks, + ).firstWhere((b) => b.attributes['element'] == 'deflist'); + final item = list.children.firstWhere( + (b) => b.attributes['element'] == 'def', + ); + c.updateWritersideProperty(list.id, 'type', 'medium'); + c.updateWritersideProperty(item.id, 'default-state', 'collapsed'); + final source = c.markdown; + expect(source, contains('group="new-sync"')); + expect(source, contains('group-key="one"')); + expect(source, contains('Keep')); + expect( + source.indexOf('title="Second"'), + lessThan(source.indexOf('title="Renamed"')), + ); + expect(source, contains('title="Third"')); + expect(source, contains('type="medium" collapsible="true"')); + expect(source, contains('title="Term" default-state="collapsed"')); + c.rebaseCommittedSource(source); + expect( + c.manageWritersideItem( + tabs.id, + itemId: first.id, + remove: true, + title: 'Tab', + ), + isTrue, + ); + expect(c.markdown, isNot(contains('title="Renamed"'))); + if (xmlFormat) expect(source, contains('src="sample.dart"')); + }, + ); + test( + '${xmlFormat ? 'XML' : 'Markdown'} topic properties keep their source location over subsequent edits', + () { + final c = BusyMarkWysiwygDocumentController( + document: xmlFormat + ? adapter.parseXml( + filePath: 'a.topic', + source: + '

    Content

    ', + )! + : const MarkdownParser() + .parse( + filePath: 'a.md', + source: + '---\ncustom: kept\n---\n\n# A\n\n## Section\n\nContent\n', + mode: MarkdownMode.writersideMarkdown, + ) + .busyDocument, + ); + final p = walk( + c.document.blocks, + ).firstWhere((b) => b.kind == BusyBlockKind.paragraph); + expect(c.updateTopicSwitcherLabel('Platform'), isTrue); + expect(c.updateTopicNavigation('for', 'chapter,procedure'), isTrue); + expect(c.updateTopicNavigation('depth', '2'), isTrue); + c.updateBlockText(p.id, 'Content edited'); + final source = c.markdown; + if (xmlFormat) { + final parsed = const WritersideDocumentParser().parseXml( + filePath: 'a.topic', + source: source, + ); + expect(parsed.rootElement!.attributes['switcher-label'], 'Platform'); + expect( + parsed.elements.where((n) => n.name == 'show-structure'), + hasLength(1), + ); + expect( + source.indexOf('

    Intro

    Content

    ', + )! + : const MarkdownParser() + .parse( + filePath: 'a.md', + source: '# A\n\nIntro\n\n## Section\n\nContent\n', + mode: MarkdownMode.writersideMarkdown, + ) + .busyDocument, + ); + final ps = walk( + c.document.blocks, + ).where((b) => b.kind == BusyBlockKind.paragraph).toList(); + expect( + c.insertWriterside( + BusyWritersideInsertCommand.tldr, + ps[1].id, + title: 'TLDR', + ), + isNotNull, + ); + expect( + c.canInsertWriterside(BusyWritersideInsertCommand.tldr, ps[1].id), + isFalse, + ); + expect( + c.canInsertWriterside(BusyWritersideInsertCommand.tldr, ps[0].id), + isTrue, + ); + expect( + c.insertWriterside( + BusyWritersideInsertCommand.tldr, + ps[0].id, + title: 'TLDR', + ), + isNotNull, + ); + expect( + c.markdown.indexOf(''), + lessThan(c.markdown.indexOf('Intro')), + ); + final group = c.insertWriterside( + BusyWritersideInsertCommand.tabs, + ps[0].id, + title: 'Tabs', + )!; + final inner = walk( + c.blockById(group)!.children, + ).firstWhere((b) => b.kind == BusyBlockKind.paragraph); + expect( + c.canInsertWriterside(BusyWritersideInsertCommand.tldr, inner.id), + isFalse, + ); + }, + ); + } + test( + 'Markdown heading insertion keeps TLDR in the selected chapter scope', + () { + final c = BusyMarkWysiwygDocumentController( + document: const MarkdownParser() + .parse( + filePath: 'a.md', + source: '# Title\n\nIntro\n\n## Chapter\n\nContent\n', + mode: MarkdownMode.writersideMarkdown, + ) + .busyDocument, + ); + final heading = c.document.blocks.firstWhere( + (b) => b.kind == BusyBlockKind.heading && b.attributes['level'] == '2', + ); + expect( + c.insertWriterside( + BusyWritersideInsertCommand.tldr, + heading.id, + title: 'TLDR', + ), + isNotNull, + ); + expect( + c.markdown.indexOf(''), + greaterThan(c.markdown.indexOf('## Chapter')), + ); + expect( + c.markdown.indexOf(''), + lessThan(c.markdown.indexOf('Content')), + ); + expect( + c.canInsertWriterside(BusyWritersideInsertCommand.tldr, heading.id), + isFalse, + ); + final root = c.document.blocks.firstWhere( + (b) => b.kind == BusyBlockKind.heading && b.attributes['level'] == '1', + ); + expect( + c.canInsertWriterside(BusyWritersideInsertCommand.tldr, root.id), + isTrue, + ); + }, + ); + test('representative command output for the pinned official builder', () async { + final output = + Platform.environment['BUSYMARK_AUTHORING_CONFORMANCE_OUTPUT']; + if (output == null) return; + final directory = Directory(output); + await directory.create(recursive: true); + const fixture = 'test/fixtures/writerside/conformance_project'; + await for (final entity in Directory(fixture).list(recursive: true)) { + final dest = '$output/${entity.path.substring(fixture.length + 1)}'; + if (entity is Directory) await Directory(dest).create(recursive: true); + if (entity is File) { + await File(dest).parent.create(recursive: true); + await entity.copy(dest); + } + } + for (final xmlFormat in [false, true]) { + final c = BusyMarkWysiwygDocumentController( + document: xmlFormat + ? adapter.parseXml( + filePath: 'authored-xml.topic', + source: + '

    Text path UI Ctrl+S

    Desktop content

    Terminal content

    ', + )! + : const MarkdownParser() + .parse( + filePath: 'authored-markdown.md', + source: + '# Authored Markdown\n\nText path UI Ctrl+S\n\n## Desktop\n\nDesktop content\n\n## Terminal\n\nTerminal content\n', + mode: MarkdownMode.writersideMarkdown, + ) + .busyDocument, + ); + final p = walk( + c.document.blocks, + ).firstWhere((b) => b.kind == BusyBlockKind.paragraph); + for (final command in [ + BusyWritersideInsertCommand.procedure, + BusyWritersideInsertCommand.tabs, + BusyWritersideInsertCommand.definitionList, + BusyWritersideInsertCommand.tldr, + BusyWritersideInsertCommand.include, + ]) { + final id = c.insertWriterside( + command, + p.id, + title: 'Authored', + attributes: switch (command) { + BusyWritersideInsertCommand.include => { + 'from': 'features.topic', + 'element-id': 'shared-conformance', + }, + BusyWritersideInsertCommand.video => { + 'src': 'https://youtu.be/BeJu9bMPLGU', + }, + _ => {}, + }, + ); + expect(id, isNotNull); + final leaf = walk( + c.blockById(id!)!.children, + ).where((b) => b.kind == BusyBlockKind.paragraph).firstOrNull; + if (leaf != null) c.updateBlockText(leaf.id, 'Authored content'); + if (command == BusyWritersideInsertCommand.procedure) { + c.updateWritersideProperty(id, 'collapsible', 'true'); + c.updateWritersideProperty(id, 'default-state', 'expanded'); + } else if (command == BusyWritersideInsertCommand.tabs) { + c.updateWritersideProperty(id, 'group', 'authoring'); + c.updateWritersideProperty( + c.blockById(id)!.children.first.id, + 'group-key', + 'first', + ); + } else if (command == BusyWritersideInsertCommand.definitionList) { + c.updateWritersideProperty(id, 'type', 'medium'); + c.updateWritersideProperty(id, 'collapsible', 'true'); + c.updateWritersideProperty( + c.blockById(id)!.children.first.id, + 'default-state', + 'expanded', + ); + } + } + c.applyInlineCommand(p.id, BusyWysiwygInlineCommand.uiControl, 0, 4); + c.applyInlineCommand(p.id, BusyWysiwygInlineCommand.filePath, 5, 9); + c.applyInlineCommand(p.id, BusyWysiwygInlineCommand.uiPath, 10, 12); + c.applyInlineCommand(p.id, BusyWysiwygInlineCommand.shortcut, 13, 19); + // The fixture defines $Save. Exercise the authored-reference property + // path as well as literal formatting without a hard-coded-shortcut + // warning from the official builder. + final shortcut = busyInlineReferenceRanges( + c.blockById(p.id)!.inlines, + ).firstWhere((range) => range.kind == BusyInlineKind.writersideShortcut); + expect(c.updateInlineReference(p.id, shortcut, 'key', r'$Save'), isTrue); + c.insertVariableReference(p.id, 'product', 19, 19); + for (final title in ['Desktop', 'Terminal']) { + final chapter = walk(c.document.blocks).firstWhere( + (b) => b.kind == BusyBlockKind.heading && b.plainText == title, + ); + expect( + c.updateWritersideProperty( + chapter.id, + 'switcher-key', + title.toLowerCase(), + ), + isTrue, + ); + } + c.updateTopicSwitcherLabel('Platform'); + c.updateTopicNavigation('for', 'chapter,procedure,def'); + final name = xmlFormat ? 'authored-xml.topic' : 'authored-markdown.md'; + await File('$output/topics/$name').writeAsString(c.markdown); + } + final tree = File('$output/conformance.tree'); + await tree.writeAsString( + (await tree.readAsString()).replaceFirst( + '', + '', + ), + ); + }); + for (final isXml in [false, true]) { + BusyMarkWysiwygDocumentController controller() => + BusyMarkWysiwygDocumentController( + document: isXml + ? adapter.parseXml( + filePath: 'x.topic', + source: '

    Text

    ', + )! + : const MarkdownParser() + .parse( + filePath: 'x.md', + source: '# Title\n\nText\n', + mode: MarkdownMode.writersideMarkdown, + validateLocalReferences: false, + ) + .busyDocument, + ); + for (final command in [ + BusyWritersideInsertCommand.procedure, + BusyWritersideInsertCommand.tabs, + BusyWritersideInsertCommand.definitionList, + BusyWritersideInsertCommand.tldr, + BusyWritersideInsertCommand.include, + BusyWritersideInsertCommand.video, + ]) { + test( + '${isXml ? 'XML' : 'Markdown'} $command inserts editable authored structure', + () { + final c = controller(); + final p = walk( + c.document.blocks, + ).lastWhere((b) => b.kind == BusyBlockKind.paragraph); + final id = c.insertWriterside( + command, + p.id, + title: 'New', + attributes: switch (command) { + BusyWritersideInsertCommand.include => { + 'from': 'lib.topic', + 'element-id': 'snippet', + }, + BusyWritersideInsertCommand.video => {'src': 'video.mp4'}, + _ => {}, + }, + ); + expect(id, isNotNull); + final inserted = c.blockById(id!)!; + expect(inserted.preserveRaw, isFalse); + final content = walk( + inserted.children, + ).where((b) => b.kind == BusyBlockKind.paragraph).firstOrNull; + if (content != null) { + c.updateBlockText(content.id, 'Authored content'); + } + final source = c.markdown; + if (content != null) expect(source, contains('Authored content')); + final reopened = isXml + ? adapter.parseXml(filePath: 'x.topic', source: source)! + : const MarkdownParser() + .parse( + filePath: 'x.md', + source: source, + mode: MarkdownMode.writersideMarkdown, + validateLocalReferences: false, + ) + .busyDocument; + expect( + walk(reopened.blocks).any( + (b) => b.attributes['element'] == inserted.attributes['element'], + ), + isTrue, + ); + }, + ); + } + for (final command in [ + BusyWysiwygInlineCommand.uiControl, + BusyWysiwygInlineCommand.filePath, + BusyWysiwygInlineCommand.uiPath, + BusyWysiwygInlineCommand.shortcut, + ]) { + test( + '${isXml ? 'XML' : 'Markdown'} $command keeps semantic type through typing', + () { + final c = controller(); + final p = walk( + c.document.blocks, + ).lastWhere((b) => b.kind == BusyBlockKind.paragraph); + c.applyInlineCommand(p.id, command, 0, 4); + c.updateBlockText(p.id, 'Texxt'); + final source = c.markdown; + expect( + source, + contains( + '<${busyMarkSemanticInlineTag(inlineKindForCommand(command))}>Texxt b.kind == BusyBlockKind.paragraph); + expect(paragraph.inlines.single.kind, inlineKindForCommand(command)); + }, + ); + } + } +} diff --git a/test/src/writerside_authoring_ui_test.dart b/test/src/writerside_authoring_ui_test.dart new file mode 100644 index 00000000..e805d8d0 --- /dev/null +++ b/test/src/writerside_authoring_ui_test.dart @@ -0,0 +1,790 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:busymark/l10n/generated/app_localizations.dart'; +import 'package:busymark/src/app/app_settings.dart'; +import 'package:busymark/src/app/busymark_design.dart'; +import 'package:busymark/src/editor/wysiwyg/writerside_editing_adapter.dart'; +import 'package:busymark/src/editor/wysiwyg/writerside_properties.dart'; +import 'package:busymark/src/editor/wysiwyg/wysiwyg_editor.dart'; +import 'package:busymark/src/editor/wysiwyg/wysiwyg_inline_controller.dart'; +import 'package:busymark/src/editor/wysiwyg/wysiwyg_session_state.dart'; +import 'package:busymark/src/markdown/busymark_document.dart'; +import 'package:busymark/src/markdown/markdown_model.dart'; +import 'package:busymark/src/markdown/markdown_parser.dart'; +import 'package:busymark/src/platform/native_menu_service.dart'; +import 'package:busymark/src/writerside/writerside_project.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +BusyDocument _document(bool xml, {String? source}) => xml + ? const WritersideEditingAdapter().parseXml( + filePath: 'a.topic', + source: source ?? '

    Text

    ', + )! + : const MarkdownParser() + .parse( + filePath: 'a.md', + source: source ?? '# Title\n\nText\n', + mode: MarkdownMode.writersideMarkdown, + validateLocalReferences: false, + ) + .busyDocument; + +Widget _app( + Widget child, { + Brightness brightness = Brightness.light, + TextDirection direction = TextDirection.ltr, + double scale = 1, +}) => MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + theme: ThemeData(brightness: brightness), + home: Directionality( + textDirection: direction, + child: MediaQuery( + data: MediaQueryData(textScaler: TextScaler.linear(scale)), + child: Scaffold(body: child), + ), + ), +); + +Finder _contentFields() => find.byWidgetPredicate( + (w) => w is TextField && w.controller is BusyMarkWysiwygTextController, +); +Finder _textField(String text) => _contentFields() + .evaluate() + .where((e) => (e.widget as TextField).controller!.text == text) + .map((e) => find.byWidget(e.widget)) + .first; + +void main() { + final binding = TestWidgetsFlutterBinding.ensureInitialized(); + const channel = MethodChannel(nativeMenuChannelName); + final labels = []; + final presentations = >[]; + setUp(() { + labels.clear(); + presentations.clear(); + binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, ( + call, + ) async { + if (call.method != 'show') return true; + final entries = (call.arguments as Map)['entries'] as List; + presentations.add(entries); + if (labels.isEmpty) return null; + final label = labels.removeAt(0); + final selected = entries.indexWhere((e) => e['label'] == label); + expect( + selected, + greaterThanOrEqualTo(0), + reason: 'Native menu must offer $label', + ); + expect(entries[selected]['enabled'], isTrue); + return selected; + }); + }); + tearDown(() { + binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, null); + }); + Future mount(WidgetTester tester, BusyMarkWysiwygEditor editor) async { + tester.view.resetPhysicalSize(); + tester.view.physicalSize = const Size(1100, 850); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget(_app(editor)); + await tester.pumpAndSettle(); + } + + Future insert(WidgetTester tester, String label) async { + await tester.pumpAndSettle(); + labels.add(label); + await tester.tap(find.byKey(const ValueKey('wysiwyg-writerside-insert'))); + await tester.pumpAndSettle(); + } + + for (final xml in [false, true]) { + testWidgets( + '${xml ? 'XML' : 'Markdown'} canceled native menu restores editing focus and selection', + (tester) async { + final changes = []; + await mount( + tester, + BusyMarkWysiwygEditor( + document: _document(xml), + onSourceChanged: (_, source) => changes.add(source), + ), + ); + await tester.tap(_textField('Text')); + final field = tester.widget(_textField('Text')); + const selection = TextSelection(baseOffset: 0, extentOffset: 4); + field.controller!.selection = selection; + await tester.pumpAndSettle(); + binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, ( + call, + ) async { + if (call.method == 'show') { + // GTK owns keyboard focus while its menu is open. Its dismissal + // returns a null selection without invoking a toolbar command. + FocusManager.instance.primaryFocus?.unfocus(); + return null; + } + return true; + }); + await tester.tap( + find.byKey(const ValueKey('wysiwyg-writerside-insert')), + ); + await tester.pumpAndSettle(); + expect(field.focusNode!.hasFocus, isTrue); + expect(field.controller!.selection, selection); + expect(changes, isEmpty); + }, + ); + for (final label in ['Procedure', 'Tabs', 'Definition List', 'TLDR']) { + testWidgets( + '${xml ? 'XML' : 'Markdown'} inserts and edits $label through native menu', + (tester) async { + var source = ''; + await mount( + tester, + BusyMarkWysiwygEditor( + document: _document(xml), + onSourceChanged: (_, s) => source = s, + ), + ); + await tester.tap(_textField('Text')); + await insert(tester, label); + expect(presentations, isNotEmpty); + await tester.enterText(_textField(''), 'Edited content'); + await tester.pumpAndSettle(); + expect(source, contains('Edited content')); + final parsed = _document(xml, source: source); + expect(parsed.isXmlTopic, xml); + expect( + source, + contains( + '', + ), + ); + expect(tester.takeException(), isNull); + }, + ); + } + for (final label in [ + 'UI Control', + 'File / Path', + 'UI Path', + 'Keyboard Shortcuts', + ]) { + testWidgets( + '${xml ? 'XML' : 'Markdown'} applies $label and restores undo', + (tester) async { + var source = ''; + await mount( + tester, + BusyMarkWysiwygEditor( + document: _document(xml), + onSourceChanged: (_, s) => source = s, + ), + ); + final field = _textField('Text'); + await tester.tap(field); + tester.widget(field).controller!.selection = + const TextSelection(baseOffset: 0, extentOffset: 4); + await tester.pumpAndSettle(); + labels.add(label); + await tester.tap(find.byTooltip('Semantic formatting')); + await tester.pumpAndSettle(); + final tag = { + 'UI Control': 'control', + 'File / Path': 'path', + 'UI Path': 'ui-path', + 'Keyboard Shortcuts': 'shortcut', + }[label]; + expect(source, contains('<$tag>Text')); + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyZ); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pumpAndSettle(); + expect(source, isNot(contains('<$tag>'))); + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyZ); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pumpAndSettle(); + expect(source, contains('<$tag>Text')); + }, + ); + } + } + testWidgets('video insertion draft and cancellation do not modify source', ( + tester, + ) async { + var source = ''; + await mount( + tester, + BusyMarkWysiwygEditor( + document: _document(true), + onSourceChanged: (_, s) => source = s, + ), + ); + await tester.tap(_textField('Text')); + await insert(tester, 'Video'); + expect(source, isEmpty); + final entry = find.byKey(const ValueKey('writerside-video-source')); + final field = find.descendant(of: entry, matching: find.byType(TextField)); + await tester.enterText(field, 'https://youtu.be/cancelled'); + await tester.tap( + find.descendant( + of: find.byType(BusyMarkWritersideProperties), + matching: find.byTooltip('Close'), + ), + ); + await tester.pumpAndSettle(); + expect(source, isEmpty); + await insert(tester, 'Video'); + await tester.enterText(field, 'https://youtu.be/example'); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pumpAndSettle(); + expect(source, contains('
    ItemValue
    B2
    A1
    + diff --git a/test/fixtures/writerside/property_contract_conformance/v.list b/test/fixtures/writerside/property_contract_conformance/v.list new file mode 100644 index 00000000..4869b02c --- /dev/null +++ b/test/fixtures/writerside/property_contract_conformance/v.list @@ -0,0 +1,6 @@ + + + + + + diff --git a/test/fixtures/writerside/property_contract_conformance/writerside.cfg b/test/fixtures/writerside/property_contract_conformance/writerside.cfg new file mode 100644 index 00000000..6992cbe4 --- /dev/null +++ b/test/fixtures/writerside/property_contract_conformance/writerside.cfg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/test/fixtures/writerside/property_defaults_conformance/c.list b/test/fixtures/writerside/property_defaults_conformance/c.list new file mode 100644 index 00000000..0dfaea6e --- /dev/null +++ b/test/fixtures/writerside/property_defaults_conformance/c.list @@ -0,0 +1,4 @@ + + + + diff --git a/test/fixtures/writerside/property_defaults_conformance/cfg/buildprofiles.xml b/test/fixtures/writerside/property_defaults_conformance/cfg/buildprofiles.xml new file mode 100644 index 00000000..01ed61be --- /dev/null +++ b/test/fixtures/writerside/property_defaults_conformance/cfg/buildprofiles.xml @@ -0,0 +1,13 @@ + + + + keymap.xml + + + + + true + false + + + diff --git a/test/fixtures/writerside/property_defaults_conformance/cfg/glossary.xml b/test/fixtures/writerside/property_defaults_conformance/cfg/glossary.xml new file mode 100644 index 00000000..1aa736e7 --- /dev/null +++ b/test/fixtures/writerside/property_defaults_conformance/cfg/glossary.xml @@ -0,0 +1,5 @@ + + + + Markup that conveys the meaning of content. + diff --git a/test/fixtures/writerside/property_defaults_conformance/conformance.tree b/test/fixtures/writerside/property_defaults_conformance/conformance.tree new file mode 100644 index 00000000..99e0deb8 --- /dev/null +++ b/test/fixtures/writerside/property_defaults_conformance/conformance.tree @@ -0,0 +1 @@ + diff --git a/test/fixtures/writerside/property_defaults_conformance/images/logo.png b/test/fixtures/writerside/property_defaults_conformance/images/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..d04c6b87bcced84af996bbe1d40bb07f246bc82b GIT binary patch literal 70 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx1|;Q0k92}1TpU9xZY5uce(*=0fklZ)(1$}V Q4=BOl>FVdQ&MBb@0I?1Zga7~l literal 0 HcmV?d00001 diff --git a/test/fixtures/writerside/property_defaults_conformance/images/logo_dark.png b/test/fixtures/writerside/property_defaults_conformance/images/logo_dark.png new file mode 100644 index 0000000000000000000000000000000000000000..d04c6b87bcced84af996bbe1d40bb07f246bc82b GIT binary patch literal 70 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx1|;Q0k92}1TpU9xZY5uce(*=0fklZ)(1$}V Q4=BOl>FVdQ&MBb@0I?1Zga7~l literal 0 HcmV?d00001 diff --git a/test/fixtures/writerside/property_defaults_conformance/images/other.png b/test/fixtures/writerside/property_defaults_conformance/images/other.png new file mode 100644 index 0000000000000000000000000000000000000000..d04c6b87bcced84af996bbe1d40bb07f246bc82b GIT binary patch literal 70 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx1|;Q0k92}1TpU9xZY5uce(*=0fklZ)(1$}V Q4=BOl>FVdQ&MBb@0I?1Zga7~l literal 0 HcmV?d00001 diff --git a/test/fixtures/writerside/property_defaults_conformance/images/other_dark.png b/test/fixtures/writerside/property_defaults_conformance/images/other_dark.png new file mode 100644 index 0000000000000000000000000000000000000000..d04c6b87bcced84af996bbe1d40bb07f246bc82b GIT binary patch literal 70 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx1|;Q0k92}1TpU9xZY5uce(*=0fklZ)(1$}V Q4=BOl>FVdQ&MBb@0I?1Zga7~l literal 0 HcmV?d00001 diff --git a/test/fixtures/writerside/property_defaults_conformance/keymap.xml b/test/fixtures/writerside/property_defaults_conformance/keymap.xml new file mode 100644 index 00000000..0581c4b8 --- /dev/null +++ b/test/fixtures/writerside/property_defaults_conformance/keymap.xml @@ -0,0 +1,12 @@ + + + + + + + + Save + Ctrl+S + + + diff --git a/test/fixtures/writerside/property_defaults_conformance/resources/sample-resource.txt b/test/fixtures/writerside/property_defaults_conformance/resources/sample-resource.txt new file mode 100644 index 00000000..50b01c78 --- /dev/null +++ b/test/fixtures/writerside/property_defaults_conformance/resources/sample-resource.txt @@ -0,0 +1 @@ +BusyMark Writerside conformance resource. diff --git a/test/fixtures/writerside/property_defaults_conformance/topics/properties-markdown.md b/test/fixtures/writerside/property_defaults_conformance/topics/properties-markdown.md new file mode 100644 index 00000000..84eb5684 --- /dev/null +++ b/test/fixtures/writerside/property_defaults_conformance/topics/properties-markdown.md @@ -0,0 +1,208 @@ +--- +switcher-label: Author's: "Desktop" \ Keys & Tools +--- + +# Markdown Properties + +Text **bold**. +{id="intro"} + +## State only {id="md-state" default-state="expanded"} + +Content. + +```text +inline +``` +{id="md-code" collapsible="false"} + +```text +``` +{id="md-file" src="sample.txt" include-lines="2-3" emphasize-lines="1"} + +![Markdown Logo][logo]{id="image-markdown" width="48" thumbnail="true"} + +[logo]: logo.png + +Before [![Original][logo]{id="source-override" src="other.png" alt="Different" width="80" style="inline"}](properties.topic) after. + +Read [one](properties.topic#image-xml){id="reference-override"} and [two][guide]. + +[guide]: properties.topic#image-paragraph + +![Original][logo]{id="empty-alt" alt=""} + +```java +hello +``` +{id="lang-override" lang="text"} + +embedded code + +Use `{id="literal-code"}`. +{id="paragraph-code-owner"} + +Use \{id="literal-escaped"\}. +{id="paragraph-escaped-owner"} + +Read [**reference**](properties.topic){id="owned-link"}. +{id="paragraph-link-owner"} + +Show ![Logo](logo.png){id="owned-image" width="24"}. +{id="paragraph-image-owner"} + +- Native first item +- Native second item + +{id="native-list" instance="conformance"} + + +
  • First item.

  • +
  • Second item.

  • +
    + +> A **note**. + +{style="note" id="native-note" instance="conformance" filter="release"} + +- [x] Checked native +- [ ] Unchecked native + +{id="checked-native-list"} + + +
  • Checked XML

  • +
  • Unchecked XML

  • +
    + +See [`{id="linked-literal"}`](properties.topic){id="code-reference"} + +Show ![\{id="image-literal"}](logo.png){id="alt-reference" width="24"} + +Native authored start override. + +3. Native parent + 4. Native nested first + 5. Native nested second +4. Native sibling + +{start="10" id="native-numbered-list"} + +Native marker starts. + +3. Native marker parent + 4. Native marker nested first + 5. Native marker nested second +4. Native marker sibling + +{id="native-marker-list"} + +Native nested authored start override. + +3. Native override parent + 4. Native override nested first + 5. Native override nested second + + {start="7" id="native-nested-list"} + +4. Native override sibling + +{start="10" id="native-override-list"} + +Converted numbered lists. + + + +
  • + +

    Parent

    + + + +
  • + +

    Nested first

    + +
  • + +
  • + +

    Nested second

    + +
  • + +
    + + + +
  • + +

    Sibling

    + +
  • + + + + + +
  • + +

    Parent

    + + + +
  • + +

    Nested first

    + +
  • + +
  • + +

    Nested second

    + +
  • + +
    + + + +
  • + +

    Sibling

    + +
  • + + + + + +
  • + +

    Parent

    + + + +
  • + +

    Nested first

    + +
  • + +
  • + +

    Nested second

    + +
  • + +
    + + + +
  • + +

    Sibling

    + +
  • + + diff --git a/test/fixtures/writerside/property_defaults_conformance/topics/properties.topic b/test/fixtures/writerside/property_defaults_conformance/topics/properties.topic new file mode 100644 index 00000000..f5160ad8 --- /dev/null +++ b/test/fixtures/writerside/property_defaults_conformance/topics/properties.topic @@ -0,0 +1,56 @@ + +

    Chapter body.

    +

    Chapter body.

    +

    Chapter body.

    +

    Chapter body.

    +

    Chapter body.

    +

    Chapter body.

    +

    Chapter body.

    +First step. +First step. +First step. +First step. +First step. +First step. +First step. +first +second +first +second +first +second +first +second +first +second +first +second +first +second +Absent state.Inherited state.Expanded state.Collapsed state. +Absent state.Inherited state.Expanded state.Collapsed state. +Absent state.Inherited state.Expanded state.Collapsed state. +Absent state.Inherited state.Expanded state.Collapsed state. +Absent state.Inherited state.Expanded state.Collapsed state. +Absent state.Inherited state.Expanded state.Collapsed state. +Absent state.Inherited state.Expanded state.Collapsed state. +

    Chapter body.

    +

    Chapter body.

    +Step. +Step. +first +second +first +second +Absent.Inherited.Collapsed.False. +Absent.Inherited.Collapsed.False. +Install <b>now</b>

    Body.

    +Child title only

    Body.

    +

    Before Inline Logo after.

    +Logo +

    Variable attributes.

    +variable code +

    Default body.

    +

    Wide body.

    +

    Full body.

    +
    diff --git a/test/fixtures/writerside/property_defaults_conformance/topics/sample.txt b/test/fixtures/writerside/property_defaults_conformance/topics/sample.txt new file mode 100644 index 00000000..ff6e6b1a --- /dev/null +++ b/test/fixtures/writerside/property_defaults_conformance/topics/sample.txt @@ -0,0 +1,3 @@ +first +second +third diff --git a/test/fixtures/writerside/property_defaults_conformance/v.list b/test/fixtures/writerside/property_defaults_conformance/v.list new file mode 100644 index 00000000..4869b02c --- /dev/null +++ b/test/fixtures/writerside/property_defaults_conformance/v.list @@ -0,0 +1,6 @@ + + + + + + diff --git a/test/fixtures/writerside/property_defaults_conformance/writerside.cfg b/test/fixtures/writerside/property_defaults_conformance/writerside.cfg new file mode 100644 index 00000000..83650a43 --- /dev/null +++ b/test/fixtures/writerside/property_defaults_conformance/writerside.cfg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/test/fixtures/wysiwyg/selection_boundaries.md b/test/fixtures/wysiwyg/selection_boundaries.md index a540256a..25d5b089 100644 --- a/test/fixtures/wysiwyg/selection_boundaries.md +++ b/test/fixtures/wysiwyg/selection_boundaries.md @@ -1,11 +1,11 @@ Previous paragraph ends here. -**The client must work before corpus population finishes.** Use synthetic published test books. A successful build of a mock-only UI is not the final integration result. +**The editor preserves selection across paragraph boundaries.** Select text with bold formatting, inline code, and links. Copying the full paragraph must preserve its final punctuation. Following paragraph ends here. -- `/mnt/Media/Linguality/gateway` -- `/mnt/Media/Linguality/learner_client` +- `/opt/example/gateway` +- `/opt/example/desktop_client` - Ordinary list text ends here. **Bold** then plain; plain then **bold**; `code` then plain; plain then `code`; [link](https://example.test); *emphasis* ends here! diff --git a/test/src/app_settings_test.dart b/test/src/app_settings_test.dart index 22478ec8..eef8ad82 100644 --- a/test/src/app_settings_test.dart +++ b/test/src/app_settings_test.dart @@ -9,6 +9,83 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:path/path.dart' as p; void main() { + test( + 'inspector starts closed after loading an old visible preference', + () async { + final store = _MemorySettingsStore(); + store.value.addAll({ + 'elementPropertiesVisible': true, + 'elementPropertiesWidth': 390.0, + }); + final container = ProviderContainer( + overrides: [localSettingsStoreProvider.overrideWithValue(store)], + ); + addTearDown(container.dispose); + final controller = container.read(appSettingsControllerProvider.notifier); + expect( + container.read(appSettingsControllerProvider).elementPropertiesVisible, + isFalse, + ); + await controller.waitUntilLoaded(); + expect( + container.read(appSettingsControllerProvider).elementPropertiesVisible, + isFalse, + ); + expect( + container.read(appSettingsControllerProvider).elementPropertiesWidth, + 390, + ); + await controller.setElementPropertiesVisible(true); + expect( + container.read(appSettingsControllerProvider).elementPropertiesVisible, + isTrue, + ); + }, + ); + test( + 'an explicit inspector request during settings hydration stays open', + () async { + final container = ProviderContainer( + overrides: [ + localSettingsStoreProvider.overrideWithValue(_MemorySettingsStore()), + ], + ); + addTearDown(container.dispose); + final controller = container.read(appSettingsControllerProvider.notifier); + await controller.setElementPropertiesVisible(true); + await controller.waitUntilLoaded(); + expect( + container.read(appSettingsControllerProvider).elementPropertiesVisible, + isTrue, + ); + }, + ); + test( + 'Element Properties preferences persist independently of navigation sidebar', + () { + final settings = AppSettings.defaults().copyWith( + sidebarVisible: false, + elementPropertiesVisible: true, + elementPropertiesWidth: 390, + ); + final restored = AppSettings.fromJson(settings.toJson()); + expect(restored.sidebarVisible, isFalse); + expect(restored.elementPropertiesVisible, isTrue); + expect(restored.elementPropertiesWidth, 390); + expect( + AppSettings.fromJson({ + 'elementPropertiesWidth': 9999, + }).elementPropertiesWidth, + 600, + ); + expect( + AppSettings.fromJson({ + 'elementPropertiesWidth': 1, + }).elementPropertiesWidth, + 260, + ); + }, + ); test( 'hydration and redundant sidebar requests do not count as user transitions', () async { diff --git a/test/src/app_smoke_test.dart b/test/src/app_smoke_test.dart index d8a65aaa..90b210f5 100644 --- a/test/src/app_smoke_test.dart +++ b/test/src/app_smoke_test.dart @@ -1,6 +1,7 @@ import 'package:busymark/src/core/path_utils.dart'; import 'package:busymark/src/platform/native_writerside_dialog_service.dart'; import 'dart:async'; +import 'package:busymark/src/editor/wysiwyg/writerside_properties.dart'; import 'dart:convert'; import 'dart:io'; @@ -10875,9 +10876,8 @@ After break. final editorImage = find.byType(MarkdownImageView); expect(editorAdmonition, findsOneWidget); expect(editorImage, findsOneWidget); - // Compare the shared document surface with the utility pane dismissed. - await tester.tap(find.byTooltip('Element Properties')); - await tester.pumpAndSettle(); + // The utility pane starts closed; hover access must not open it. + expect(find.byType(BusyMarkWritersideProperties), findsNothing); expect( find.descendant( of: editorAdmonition, @@ -14041,7 +14041,7 @@ Before [![Inline logo](inline-logo.png)](inline-guide.md) after. ..value = AppSettings.defaults() .copyWith(documentViewMode: DocumentViewModePreference.preview) .toJson(); - final service = _SearchWorkspaceService(_fsrsReadmeSearchSource()); + final service = _SearchWorkspaceService(_technicalReadmeSearchSource()); final container = ProviderContainer( overrides: [ ..._smokeAccentOverrides, @@ -14334,12 +14334,12 @@ Before [![Inline logo](inline-logo.png)](inline-guide.md) after. tester.view.resetDevicePixelRatio(); }); - const target = 'conda activate fsrs-service'; + const target = 'conda activate example-service'; final settingsStore = _MemorySettingsStore() ..value = AppSettings.defaults() .copyWith(documentViewMode: DocumentViewModePreference.preview) .toJson(); - final service = _SearchWorkspaceService(_fsrsReadmeSearchSource()); + final service = _SearchWorkspaceService(_technicalReadmeSearchSource()); final container = ProviderContainer( overrides: [ ..._smokeAccentOverrides, @@ -14767,19 +14767,19 @@ Rect _rightmostTextRect(WidgetTester tester, String text) { return tester.getRect(rightmostFinder!); } -String _fsrsReadmeSearchSource() { +String _technicalReadmeSearchSource() { return [ - '# FSRS Service', + '# Rendering Service', '', - 'Stateless FastAPI microservice exposing the **py-fsrs (FSRS 6.x)** scheduling algorithm via a strict OpenAPI contract.', + 'Stateless document-rendering service exposing the **rendering API (version 1.x)** through a strict OpenAPI contract.', '', 'This service performs **pure computation only**.', 'Persistence, authentication, authorization, and rate-limiting are expected to be handled by an upstream service (e.g.', - 'Spring Boot).', + 'a separate gateway).', '', 'This service is designed to be deployed:', '', - '* Behind an API gateway or Spring Boot service', + '* Behind an API gateway or reverse proxy', '* Without direct public exposure', '* Without authentication logic', '', @@ -14788,15 +14788,15 @@ String _fsrsReadmeSearchSource() { '## Conda Environment Setup', '', '```bash', - 'conda create -n fsrs-service python=3.11', - 'conda activate fsrs-service', + 'conda create -n example-service python=3.11', + 'conda activate example-service', 'python -m pip install -e .', '```', '', 'Install test dependencies:', '', '```bash', - 'conda install -n fsrs-service pytest', + 'conda install -n example-service pytest', '```', '', '---', @@ -14804,36 +14804,36 @@ String _fsrsReadmeSearchSource() { '## Run the Service', '', '```bash', - 'uvicorn fsrs_service.main:app --host 127.0.0.1 --port 8000', + 'uvicorn example_service.main:app --host 127.0.0.1 --port 8000', '```', '', 'An application factory is also available:', '', '```python', - 'from fsrs_service.main import create_app', + 'from example_service.main import create_app', '', 'app = create_app()', '```', '', '---', '', - '### Scheduler Operations', + '### Rendering Operations', '', - '* `POST /v1/schedulers/retrievability`', - ' Compute recall probability at a given time.', + '* `POST /v1/documents/validate`', + ' Validate document syntax before rendering.', '', - '* `POST /v1/schedulers/review`', - ' Apply a review rating and return:', + '* `POST /v1/documents/render`', + ' Render a document and return:', '', ' ```json', - ' { "card": {}, "review_log": {} }', + ' { "document": {}, "diagnostics": {} }', ' ```', '', - '* `POST /v1/schedulers/reschedule`', - ' Recompute card state **from review history**.', + '* `POST /v1/documents/rebuild`', + ' Rebuild document output **from source content**.', '', - ' This endpoint **does not accept a target datetime**.', - ' It replays `review_logs` to derive the card state.', + ' This endpoint **does not accept remote resources**.', + ' It reads `source_blocks` to derive the document output.', '', for (var index = 0; index < 50; index += 1) ...[ 'Trailing content $index keeps the preview scrollable.', diff --git a/test/src/busymark_design_test.dart b/test/src/busymark_design_test.dart index 000d9e24..eded232a 100644 --- a/test/src/busymark_design_test.dart +++ b/test/src/busymark_design_test.dart @@ -1530,6 +1530,7 @@ void main() { ); expect(groupedMaterial.elevation, theme.cardTheme.elevation); expect(groupedMaterial.shadowColor, Colors.transparent); + expect(groupedMaterial.clipBehavior, Clip.antiAlias); expect( _nativeCardDecoration(tester, groupedSurface).shadows, BusyMarkShadow.nativeCardShadows(theme.colorScheme.shadow), @@ -1557,6 +1558,7 @@ void main() { tester.widget(find.byType(Divider)).color, colors.cardShade, ); + expect(tester.widget(find.byType(Divider)).height, 1); expect( DefaultTextStyle.of(tester.element(find.text('One'))).style.color, isNot(colors.mutedForeground), @@ -2083,7 +2085,15 @@ void main() { ), ), ); - expect(popups, hasLength(3)); + expect(popups, hasLength(2)); + expect( + popups.where((popup) => popup.tooltip == 'Writerside'), + hasLength(1), + ); + expect( + popups.where((popup) => popup.tooltip == 'Semantic formatting'), + isEmpty, + ); expect(popups.every((popup) => !popup.transparent), isTrue); expect(popups.every((popup) => popup.elevated), isTrue); expect( @@ -2124,8 +2134,7 @@ void main() { l10n.inlineMath, l10n.hardLineBreak, l10n.insertBlankLine, - l10n.admonition, - l10n.wsSemanticFormats, + l10n.writerside, l10n.blockquote, l10n.codeBlock, l10n.displayMath, @@ -2297,6 +2306,41 @@ void main() { }); } + testWidgets('dialog title and body share a clipped surface in both themes', ( + tester, + ) async { + for (final brightness in Brightness.values) { + await tester.pumpWidget( + MaterialApp( + theme: buildBusyMarkTheme( + brightness: brightness, + accentColor: const Color(0xFFE95420), + ), + home: const Scaffold( + body: BusyMarkDialogShell( + title: 'Dialog', + children: [Text('Body')], + ), + ), + ), + ); + final shell = find.byType(BusyMarkDialogShell); + final dialog = tester.widget( + find.descendant(of: shell, matching: find.byType(Dialog)), + ); + final titleBar = tester.widget( + find.descendant(of: shell, matching: find.byType(YaruDialogTitleBar)), + ); + expect( + dialog.backgroundColor, + busyMarkDialogSurfaceColor(tester.element(shell)), + ); + expect(titleBar.backgroundColor, dialog.backgroundColor); + expect(dialog.surfaceTintColor, dialog.backgroundColor); + expect(dialog.clipBehavior, Clip.antiAlias); + } + }); + testWidgets('dialog actions wrap at narrow localized text widths', ( tester, ) async { diff --git a/test/src/busymark_document_test.dart b/test/src/busymark_document_test.dart index a87958fb..1315a525 100644 --- a/test/src/busymark_document_test.dart +++ b/test/src/busymark_document_test.dart @@ -5120,7 +5120,13 @@ void main() {} await tester.pump(); expect(find.byType(Image), findsOneWidget); - expect(find.text('rpi_1.jpg'), findsNothing); + expect( + find.descendant( + of: find.byKey(const ValueKey('wysiwyg-document-content')), + matching: find.text('rpi_1.jpg'), + ), + findsNothing, + ); expect( find.descendant( of: find.byKey(const ValueKey('wysiwyg-document-content')), @@ -5132,7 +5138,13 @@ void main() {} await tester.tap(find.byKey(const ValueKey('wysiwyg-image-block-image'))); await tester.pumpAndSettle(); - expect(find.text('Image'), findsOneWidget); + expect( + find.descendant( + of: find.byType(BusyMarkModalEditorScaffold), + matching: find.text('Image'), + ), + findsOneWidget, + ); expect(find.text('Apply'), findsOneWidget); expect(find.byType(BusyMarkModalEditorSurface), findsOneWidget); expect(find.byType(BusyMarkModalEditorScaffold), findsOneWidget); diff --git a/test/src/git/git_author_identity_dialog_test.dart b/test/src/git/git_author_identity_dialog_test.dart index 601d9547..3cd1b9b9 100644 --- a/test/src/git/git_author_identity_dialog_test.dart +++ b/test/src/git/git_author_identity_dialog_test.dart @@ -46,15 +46,15 @@ void main() { of: find.byType(BusyMarkModalEditorScaffold), matching: find.byType(TextFormField), ); - await tester.enterText(fields.at(0), 'Albert Gee'); + await tester.enterText(fields.at(0), 'Example Author'); await tester.sendKeyEvent(LogicalKeyboardKey.enter); - await tester.enterText(fields.at(1), 'albert@example.com'); + await tester.enterText(fields.at(1), 'author@example.com'); await tester.sendKeyEvent(LogicalKeyboardKey.enter); await tester.pumpAndSettle(); expect(find.text('Git Author Identity'), findsNothing); - expect(result?.name, 'Albert Gee'); - expect(result?.email, 'albert@example.com'); + expect(result?.name, 'Example Author'); + expect(result?.email, 'author@example.com'); expect(result?.globally, isTrue); }); @@ -110,8 +110,8 @@ void main() { of: find.byType(BusyMarkModalEditorScaffold), matching: find.byType(TextFormField), ); - await tester.enterText(fields.at(0), 'Albert Gee'); - await tester.enterText(fields.at(1), 'albert@example.com'); + await tester.enterText(fields.at(0), 'Example Author'); + await tester.enterText(fields.at(1), 'author@example.com'); await tester.pump(); await tester.tap(find.text('Save and Commit')); await tester.pumpAndSettle(); @@ -120,8 +120,8 @@ void main() { 'Document strict Git', 'Document strict Git', ]); - expect(controller.configuredName, 'Albert Gee'); - expect(controller.configuredEmail, 'albert@example.com'); + expect(controller.configuredName, 'Example Author'); + expect(controller.configuredEmail, 'author@example.com'); expect(controller.configuredGlobally, isTrue); }); } diff --git a/test/src/git/git_controller_test.dart b/test/src/git/git_controller_test.dart index c563535d..fbf0f7b2 100644 --- a/test/src/git/git_controller_test.dart +++ b/test/src/git/git_controller_test.dart @@ -427,14 +427,14 @@ void main() { await controller.refresh(); final configured = await controller.configureAuthorIdentity( - name: 'Albert Gee', - email: 'albert@example.com', + name: 'Example Author', + email: 'author@example.com', globally: true, ); expect(configured, isTrue); - expect(gateway.authorName, 'Albert Gee'); - expect(gateway.authorEmail, 'albert@example.com'); + expect(gateway.authorName, 'Example Author'); + expect(gateway.authorEmail, 'author@example.com'); expect(gateway.authorIdentityGlobal, isTrue); }); diff --git a/test/src/localization_audit_test.dart b/test/src/localization_audit_test.dart index 8cae6bf8..781ead6c 100644 --- a/test/src/localization_audit_test.dart +++ b/test/src/localization_audit_test.dart @@ -1311,6 +1311,7 @@ const _sharedEnglishMatches = { const _localeSpecificEnglishMatches = >{ 'de': { + 'wsElementSelector', // Element is also the German noun. 'tocTemplateName', // Name and Format are also German words. 'tocTemplateFormat', 'exportLayout', @@ -1327,7 +1328,7 @@ const _localeSpecificEnglishMatches = >{ 'instanceVersion', 'instanceStatus', }, - 'et': {'link', 'gitCommit'}, + 'et': {'link', 'gitCommit', 'wsElementSelector'}, // Estonian: element. 'es': { 'editor', 'gitCommit', @@ -1336,6 +1337,7 @@ const _localeSpecificEnglishMatches = >{ 'shortcutGroupGeneral', }, 'fr': { + 'wsModule', // Module is also the French noun. 'htmlInstance', // Instance is also the French technical term. 'actions', 'source', @@ -1365,13 +1367,20 @@ const _localeSpecificEnglishMatches = >{ 'gitCommit', }, 'nb': { + 'wsElementSelector', // Norwegian Bokmål: element. 'systemTheme', 'systemLanguage', 'gitCommit', 'instanceStatus', 'tocTemplateFormat', }, - 'pl': {'folder', 'foldKindTag', 'aiModel', 'tocTemplateFormat'}, + 'pl': { + 'folder', + 'foldKindTag', + 'aiModel', + 'tocTemplateFormat', + 'wsElementSelector', // Element is also the Polish noun. + }, 'pt_BR': { 'exportLayout', 'editor', @@ -1388,6 +1397,8 @@ const _localeSpecificEnglishMatches = >{ 'ja': {'gitFetch', 'gitCommit', 'pdfPageSizeLetter'}, 'ko': {'gitDiff', 'gitFetch', 'gitCommit', 'pdfPageSizeLetter'}, 'id': { + 'wsDefaultProperty', // Default is an Indonesian computing loanword. + 'wsDefaultPropertyOrigin', 'tocTemplatesInternal', // Reviewed Indonesian technical terms. 'tocTemplateFormat', 'editor', @@ -1415,6 +1426,8 @@ const _localeSpecificEnglishMatches = >{ 'pdfPageSizeLetter', }, 'nl': { + 'wsElementSelector', // Element and module are also Dutch nouns. + 'wsModule', 'aboutWebsite', 'editor', 'recent', diff --git a/test/src/markdown_parser_test.dart b/test/src/markdown_parser_test.dart index 96a8f62e..645eed08 100644 --- a/test/src/markdown_parser_test.dart +++ b/test/src/markdown_parser_test.dart @@ -1320,8 +1320,8 @@ void main() { 'bad\n' '\n' '\n' - '\n' - '\n' + '\n' + '\n' 'ftp\n' 'custom\n' 'protocol relative\n', diff --git a/test/src/native_writerside_dialog_service_test.dart b/test/src/native_writerside_dialog_service_test.dart index a08cdc30..7ab50806 100644 --- a/test/src/native_writerside_dialog_service_test.dart +++ b/test/src/native_writerside_dialog_service_test.dart @@ -90,6 +90,7 @@ void main() { expect(result.value?.instanceTitle, 'Instance title'); expect(result.value?.tocTitle, 'TOC title'); expect(call?.method, 'showEditTitle'); + expect((call!.arguments as Map)['instanceTitleEditable'], isTrue); expect((call?.arguments as Map)['textDirection'], 'rtl'); expect( (call?.arguments as Map)['initialTitle'], diff --git a/test/src/nextcloud_notes/notes_milestone_test.dart b/test/src/nextcloud_notes/notes_protocol_regression_test.dart similarity index 100% rename from test/src/nextcloud_notes/notes_milestone_test.dart rename to test/src/nextcloud_notes/notes_protocol_regression_test.dart diff --git a/test/src/nextcloud_notes/notes_milestone_repository_test.dart b/test/src/nextcloud_notes/notes_repository_regression_test.dart similarity index 100% rename from test/src/nextcloud_notes/notes_milestone_repository_test.dart rename to test/src/nextcloud_notes/notes_repository_regression_test.dart diff --git a/test/src/nextcloud_notes/notes_workspace_m2_test.dart b/test/src/nextcloud_notes/notes_workspace_workflows_test.dart similarity index 100% rename from test/src/nextcloud_notes/notes_workspace_m2_test.dart rename to test/src/nextcloud_notes/notes_workspace_workflows_test.dart diff --git a/test/src/notes_workspace_m2_ui_test.dart b/test/src/notes_workspace_m2_ui_test.dart index c96bd9b5..e376485b 100644 --- a/test/src/notes_workspace_m2_ui_test.dart +++ b/test/src/notes_workspace_m2_ui_test.dart @@ -23,6 +23,32 @@ Widget _app(Widget child) => MaterialApp( home: Scaffold(body: child), ); void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + const windowChannel = MethodChannel('yaru_window'); + const windowEvents = EventChannel('yaru_window/events'); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + late int stateRequests; + setUp(() { + stateRequests = 0; + // The real Yaru dialog title bar subscribes to native window state. Keep + // this file independent of plugin registration and async completion timing. + messenger.setMockMethodCallHandler(windowChannel, (call) async { + if (call.method == 'state') { + stateRequests++; + return {}; + } + return null; + }); + messenger.setMockStreamHandler( + windowEvents, + MockStreamHandler.inline(onListen: (_, _) {}, onCancel: (_) {}), + ); + }); + tearDown(() { + messenger.setMockMethodCallHandler(windowChannel, null); + messenger.setMockStreamHandler(windowEvents, null); + }); test( 'Quick Open ranks exact/prefix/title/path matches with stable identities', () { @@ -83,6 +109,7 @@ void main() { ); await tester.tap(find.text('launch')); await _drain(tester); + expect(stateRequests, greaterThan(0)); await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); await tester.sendKeyEvent(LogicalKeyboardKey.enter); await tester.pumpAndSettle(); diff --git a/test/src/preview_builder_test.dart b/test/src/preview_builder_test.dart index b4e9cbb5..4cb15685 100644 --- a/test/src/preview_builder_test.dart +++ b/test/src/preview_builder_test.dart @@ -140,17 +140,17 @@ void main() { final parsed = parser.parse( filePath: 'README.md', source: [ - '# FSRS Service', + '# Rendering Service', '', - 'Stateless FastAPI microservice exposing the **py-fsrs (FSRS 6.x)** scheduling algorithm via a strict OpenAPI contract.', + 'Stateless document-rendering service exposing the **rendering API (version 1.x)** through a strict OpenAPI contract.', '', 'This service performs **pure computation only**.', 'Persistence, authentication, authorization, and rate-limiting are expected to be handled by an upstream service (e.g.', - 'Spring Boot).', + 'a separate gateway).', '', 'This service is designed to be deployed:', '', - '* Behind an API gateway or Spring Boot service', + '* Behind an API gateway or reverse proxy', '* Without direct public exposure', '* Without authentication logic', '', @@ -159,15 +159,15 @@ void main() { '## Conda Environment Setup', '', '```bash', - 'conda create -n fsrs-service python=3.11', - 'conda activate fsrs-service', + 'conda create -n example-service python=3.11', + 'conda activate example-service', 'python -m pip install -e .', '```', '', 'Install test dependencies:', '', '```bash', - 'conda install -n fsrs-service pytest', + 'conda install -n example-service pytest', '```', '', '---', @@ -175,13 +175,13 @@ void main() { '## Run the Service', '', '```bash', - 'uvicorn fsrs_service.main:app --host 127.0.0.1 --port 8000', + 'uvicorn example_service.main:app --host 127.0.0.1 --port 8000', '```', '', 'An application factory is also available:', '', '```python', - 'from fsrs_service.main import create_app', + 'from example_service.main import create_app', '', 'app = create_app()', '```', @@ -233,19 +233,19 @@ void main() { final parsed = parser.parse( filePath: 'topic.md', source: - '- [How to Install Matter on RPi](https://mattercoder.com/' - 'codelabs/how-to-install-matter-on-rpi/)\n', + '- [Install a rendering service](https://example.test/' + 'guides/install-rendering-service/)\n', ); final preview = previewBuilder.build(parsed); final list = preview.blocks.singleWhere( (block) => block.kind == PreviewBlockKind.list, ); - expect(list.text, 'How to Install Matter on RPi'); + expect(list.text, 'Install a rendering service'); expect(list.inlines.single.kind, PreviewInlineKind.link); expect( list.inlines.single.destination, - 'https://mattercoder.com/codelabs/how-to-install-matter-on-rpi/', + 'https://example.test/guides/install-rendering-service/', ); }); @@ -384,36 +384,6 @@ void main() { expect(Uri.decodeComponent(image.attributes['src']!), destination); }); - test('preview renders quote characters as text instead of HTML entities', () { - final parsed = parser.parse( - filePath: 'topic.md', - source: 'Use "group" here.\n', - ); - final preview = previewBuilder.build(parsed); - final paragraph = preview.blocks.single; - - expect(paragraph.text, 'Use "group" here.'); - expect(paragraph.inlines.single.text, 'Use "group" here.'); - }); - - test('preview preserves pasted local image destinations with spaces', () { - const destination = - '/home/albert/Pictures/Screenshots/' - 'Screenshot From 2026-06-15 03-27-53.png'; - final parsed = parser.parse( - filePath: 'topic.md', - source: '![Screenshot From 2026-06-15 03-27-53.png]($destination)\n', - validateLocalReferences: false, - ); - final preview = previewBuilder.build(parsed); - final image = preview.blocks.singleWhere( - (block) => block.kind == PreviewBlockKind.image, - ); - - expect(image.text, 'Screenshot From 2026-06-15 03-27-53.png'); - expect(Uri.decodeComponent(image.attributes['src']!), destination); - }); - test('preview treats single newlines inside paragraphs as soft breaks', () { final parsed = parser.parse( filePath: 'topic.md', @@ -709,13 +679,13 @@ Also visible. test('preview does not make raw HTML links active without href', () { final parsed = parser.parse( filePath: 'topic.md', - source: 'See file:///home/albert/private.md now.\n', + source: 'See file:///home/example/private.md now.\n', ); final preview = previewBuilder.build(parsed); final paragraph = preview.blocks.single; final inlines = _flattenInlines(paragraph.inlines).toList(); - expect(paragraph.text, 'See file:///home/albert/private.md now.'); + expect(paragraph.text, 'See file:///home/example/private.md now.'); expect( inlines.map((inline) => inline.kind), isNot(contains(PreviewInlineKind.link)), @@ -725,12 +695,12 @@ Also visible. test('preview rejects absolute local image paths in raw HTML', () { final parsed = parser.parse( filePath: 'topic.md', - source: 'Private\n', + source: 'Private\n', ); final preview = previewBuilder.build(parsed); expect(preview.blocks.single.kind, PreviewBlockKind.raw); - expect(preview.blocks.single.text, contains('/home/albert/private.png')); + expect(preview.blocks.single.text, contains('/home/example/private.png')); }); test('preview rejects deeply nested raw HTML before conversion', () { diff --git a/test/src/source_audit_test.dart b/test/src/source_audit_test.dart index 844f0d88..db8681c5 100644 --- a/test/src/source_audit_test.dart +++ b/test/src/source_audit_test.dart @@ -233,57 +233,6 @@ void main() { } }); - test('grouped action rows use one BusyMark-owned rounded surface', () { - final design = File('lib/src/app/busymark_design.dart').readAsStringSync(); - - expect(design, contains('class _BusyMarkGroupedListSurface')); - expect(design, contains('final fallbackShape = RoundedRectangleBorder')); - expect(design, contains('BorderRadius.circular(BusyMarkRadius.lg)')); - expect(design, contains('this.clipBehavior = Clip.antiAlias')); - expect(design, contains('height: 1')); - expect(design, isNot(contains('YaruTileList'))); - expect(design, isNot(contains('YaruBorderContainer'))); - }); - - test('BusyMark dialog title bars use the same surface as dialog body', () { - final design = File('lib/src/app/busymark_design.dart').readAsStringSync(); - final dialogChrome = RegExp( - r'Color busyMarkDialogSurfaceColor[\s\S]*?class BusyMarkDialogShell', - ).firstMatch(design)!.group(0)!; - final dialogShell = RegExp( - r'class BusyMarkDialogShell[\s\S]*?abstract final class BusyMarkPushButton', - ).firstMatch(design)!.group(0)!; - - expect(dialogChrome, contains('busyMarkDialogSurfaceColor(context)')); - expect(dialogChrome, contains('YaruDialogTitleBar(')); - expect(dialogChrome, contains('backgroundColor: dialogSurface')); - expect(dialogChrome, contains('border:')); - expect(dialogShell, contains('child: Dialog(')); - expect(dialogShell, contains('clipBehavior: Clip.antiAlias')); - }); - - test('BusyMark dialog buttons are thin semantic framework adapters', () { - final design = File('lib/src/app/busymark_design.dart').readAsStringSync(); - final dialogButton = RegExp( - r'class BusyMarkDialogButton[\s\S]*?class BusyMarkFloatingTextEntryGroup', - ).firstMatch(design)!.group(0)!; - - expect(design, contains('abstract final class BusyMarkPushButton')); - expect(design, contains('static FilledButton standard(')); - expect(design, contains('static FilledButton standardIcon(')); - expect(design, contains('return FilledButton.icon(')); - expect(design, contains('static ElevatedButton suggested(')); - expect(design, contains('static ElevatedButton destructive(')); - expect(dialogButton, contains('BusyMarkPushButton.standard(')); - expect(dialogButton, contains('BusyMarkPushButton.suggested(')); - expect(dialogButton, contains('BusyMarkPushButton.destructive(')); - expect(dialogButton, isNot(contains('FocusableActionDetector('))); - expect(dialogButton, isNot(contains('GestureDetector('))); - expect(dialogButton, isNot(contains('busyMarkSurfaceDecoration('))); - expect(dialogButton, isNot(contains('minHeight:'))); - expect(dialogButton, isNot(contains('minWidth:'))); - }); - test('Writerside New Topic uses the established modal editor components', () { final workspace = File( 'lib/src/workspace/presentation/workspace_screen.dart', @@ -1771,95 +1720,6 @@ void main() { expect(preview, contains('span.startOffset')); }); - test('source editor line numbers use measured editor layout', () { - final sourceEditor = File( - 'lib/src/editor/source/source_editor.dart', - ).readAsStringSync(); - final sourceGutter = File( - 'lib/src/editor/source/source_gutter.dart', - ).readAsStringSync(); - final sourceController = File( - 'lib/src/editor/source/source_controller.dart', - ).readAsStringSync(); - - expect(sourceEditor, contains('isCollapsed: true')); - expect( - sourceEditor, - contains('_sourceLineHeight(context, sourceStrutStyle)'), - ); - expect(sourceEditor, contains('_SourceRenderedTextLayer')); - expect(sourceController, contains('renderText = false')); - expect(sourceEditor, contains('controller.buildSourceTextSpan')); - expect(sourceGutter, contains('TextPainter(')); - expect(sourceGutter, contains('computeLineMetrics()')); - expect(sourceGutter, contains('getOffsetForCaret')); - expect(sourceGutter, contains('sourceTextHeightForLine')); - expect(sourceEditor, contains('_CollapsedSourceLineOverlay')); - expect(sourceEditor, contains('_sourceStrutStyle(')); - expect(sourceEditor, contains('folded: _foldedRegionKeys.isNotEmpty')); - expect(sourceEditor, contains('if (folded)')); - expect(sourceEditor, contains('return null')); - expect( - sourceEditor, - contains('StrutStyle.fromTextStyle(_sourceTextStyle)'), - ); - expect(sourceEditor, contains('strutStyle: sourceStrutStyle')); - expect(sourceEditor, contains('TextOverflow.ellipsis')); - expect(sourceEditor, contains('Color.alphaBlend')); - expect(sourceEditor, contains("'\$trimmed ...'")); - }); - - test('document view modes drive source preview and split layouts', () { - final settings = File('lib/src/app/app_settings.dart').readAsStringSync(); - final workspace = File( - 'lib/src/workspace/presentation/workspace_screen.dart', - ).readAsStringSync(); - final sourceController = File( - 'lib/src/editor/source/source_controller.dart', - ).readAsStringSync(); - final settingsScreen = File( - 'lib/src/workspace/presentation/settings_screen.dart', - ).readAsStringSync(); - - expect( - settings, - contains( - 'enum DocumentViewModePreference { editor, source, preview, split }', - ), - ); - expect( - settings, - contains('documentViewMode: DocumentViewModePreference.editor'), - ); - expect(settings, contains('Future setDocumentViewMode')); - expect(workspace, contains('final editorVisible = widget.viewMode ==')); - expect(workspace, contains('DocumentViewModePreference.editor')); - expect( - workspace, - contains('widget.viewMode != DocumentViewModePreference.preview'), - ); - expect( - workspace, - contains('widget.viewMode != DocumentViewModePreference.source'), - ); - expect(workspace, contains('BusyMarkWysiwygEditor')); - expect(sourceController, contains('visualMarkdown = false')); - expect(workspace, isNot(contains('class _VisualMarkdownEditorPane'))); - expect(workspace, contains('if (sourceVisible && previewVisible)')); - expect(workspace, contains('if (previewVisible)')); - expect(workspace, isNot(contains('class _DiffSharedHunkHeader'))); - expect( - workspace, - contains('target: _diffChangeTarget(diff, _currentChangeIndex)'), - ); - expect(workspace, contains('format: context.l10n.gitDiffHunkRange')); - expect(workspace, contains('noLinesText: context.l10n.gitDiffNoLines')); - expect(workspace, contains('showFileActions: !splitVisible')); - expect(workspace, contains('showHunkHeaders: !splitVisible')); - expect(settingsScreen, isNot(contains('Show preview pane'))); - expect(settingsScreen, isNot(contains('setPreviewVisible'))); - }); - test('WYSIWYG toolbar exposes fuller Markdown controls', () { final toolbar = File( 'lib/src/editor/wysiwyg/wysiwyg_toolbar.dart', @@ -1933,19 +1793,24 @@ void main() { expect(serializer, contains('String _listItem(')); expect(serializer, contains('String _indentBlock(')); expect(toolbar, isNot(contains('transparent: true'))); - expect(RegExp(r'transparent: false').allMatches(toolbar), hasLength(5)); + expect(RegExp(r'transparent: false').allMatches(toolbar), hasLength(3)); expect(toolbar, contains('BusyMarkHeaderIconButton(')); + expect(toolbar, contains('BusyMarkSubmenuItem(')); + expect(toolbar, contains('BusyWritersideToolbarSemantic')); + expect(toolbar, contains('context.l10n.wsInsertElements')); + expect(toolbar, contains('context.l10n.wsAdmonitionsAndQuotes')); + expect(toolbar, contains('context.l10n.wsSemanticText')); expect(toolbar, contains('_editorToolbarButtonBackground(context)')); expect(toolbar, contains('return theme.colorScheme.primary')); expect( RegExp( r'foregroundColor: BusyMarkLinuxPalette\.white', ).allMatches(toolbar), - hasLength(5), + hasLength(3), ); expect(toolbar, isNot(contains('busyMarkContainedControlBackground('))); expect(toolbar, isNot(contains('foregroundColor: colors.foreground'))); - expect(RegExp(r'elevated: true').allMatches(toolbar), hasLength(5)); + expect(RegExp(r'elevated: true').allMatches(toolbar), hasLength(3)); expect(toolbar, isNot(contains('accented: true'))); expect(toolbar, contains('clipBehavior: Clip.hardEdge')); expect(toolbar, contains('hitTestBehavior: HitTestBehavior.deferToChild')); diff --git a/test/src/source_autocomplete_test.dart b/test/src/source_autocomplete_test.dart index f52b3c4e..6ca9aa34 100644 --- a/test/src/source_autocomplete_test.dart +++ b/test/src/source_autocomplete_test.dart @@ -4,6 +4,76 @@ import 'package:busymark/src/writerside/writerside_project.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { + test( + 'list type completion offers the independently verified checkbox value', + () { + const source = '(); + messenger.setMockMethodCallHandler(channel, (call) async { + if (call.method == 'renderMermaid') return nativeResponse.future; + if (call.method == 'cancelRender') return true; + throw MissingPluginException(); + }); + const host = PlatformWebRenderHost(channel: channel); + final token = VisualizationCancellationToken(); + final operation = host.renderMermaid( + source: 'graph TD; A-->B', + theme: VisualizationTheme.light, + cancellationToken: token, + ); + final assertion = expectLater( + operation, + throwsA(isA()), + ); + token.cancel(); + nativeResponse.completeError( + PlatformException(code: 'visualization.cancelled'), + ); + await assertion; + }); + test('times out and requests native cancellation', () async { final renderCompleter = Completer(); String? renderRequestId; diff --git a/test/src/workspace_controller_test.dart b/test/src/workspace_controller_test.dart index 12dca0e2..3c4054ed 100644 --- a/test/src/workspace_controller_test.dart +++ b/test/src/workspace_controller_test.dart @@ -41,9 +41,251 @@ import 'package:path/path.dart' as p; import 'package:xml/xml.dart'; void main() { + for (final change in ['session', 'source']) { + testWidgets( + 'clean close validates source identity during $change updates', + (tester) async { + final service = _ClosingAutosaveWorkspaceService(); + final creating = _createControllerHarness(service: service); + await tester.pump(Duration.zero); + final harness = await creating; + await harness.settingsController.setValidateOnEdit(false); + await harness.settingsController.setAutoSave(false); + final controller = harness.controller; + await controller.openPath(service.path); + final before = controller.state.activeBuffer!; + await controller.openActiveFile(service.bPath); + await controller.activateDocumentBuffer(before.id); + service.pauseActivation = true; + final closing = controller.closeDocumentBuffer(before.id); + await tester.pump(); + expect(service.activationStarted.isCompleted, isTrue); + if (change == 'session') { + controller.updateActiveEditorState( + before.editorState.copyWith( + selection: const TextSelection.collapsed(offset: 2), + ), + ); + } else { + controller.updateActiveSourceText('# New unsaved content\n'); + } + service.releaseActivation.complete(); + await tester.pump(); + expect(await closing, change == 'session'); + if (change == 'source') { + expect(controller.state.activeBuffer!.id, before.id); + expect(controller.state.activeText, '# New unsaved content\n'); + expect(controller.state.activeBuffer!.isDirty, isTrue); + } else { + expect( + controller.state.documentBuffers.any((b) => b.id == before.id), + isFalse, + ); + } + }, + ); + } _registerResponsivenessTests(); _registerCorrectionBoundaryTests(); _registerReferenceMoveTests(); + for (final outerOverride in [false, true]) { + test( + 'numbered item property preserves starts through buffer undo redo save close reopen ($outerOverride)', + () async { + final root = await _createWritableWritersideFixture( + 'numbered-inspector', + ); + addTearDown(() => root.delete(recursive: true)); + final file = File(p.join(root.path, 'topics/numbered.md')); + final original = + '# Numbered\n\n3. Parent\n 4. Nested first\n 5. Nested second\n4. Sibling\n' + '${outerOverride ? '\n{start="10" id="numbered-list"}\n' : ''}'; + await file.writeAsString(original); + final harness = await _createControllerHarness(); + await harness.settingsController.setAutoSave(false); + final controller = harness.controller._notifier; + await controller.openPath(root.path); + await controller.openActiveFile(file.path); + final buffer = controller.state.activeBuffer!; + BusyDocument parseNumbered(String source) => const MarkdownParser() + .parse( + filePath: file.path, + source: source, + mode: MarkdownMode.writersideMarkdown, + ) + .busyDocument; + final visual = BusyMarkWysiwygDocumentController( + document: parseNumbered(buffer.text), + ); + addTearDown(visual.dispose); + final target = visual.document.blocks[1].children.first; + expect( + visual.updateWritersideProperty(target.id, 'id', 'parent-item'), + isTrue, + ); + final converted = visual.markdown; + void verify(String source) { + final list = parseNumbered( + source, + ).blocks.singleWhere((b) => b.attributes['element'] == 'list'); + expect(list.attributes['start'], outerOverride ? '10' : '3'); + final item = list.children.firstWhere( + (b) => b.attributes['element'] == 'li', + ); + expect(item.attributes['id'], 'parent-item'); + expect( + item.children + .singleWhere((b) => b.attributes['element'] == 'list') + .attributes['start'], + '4', + ); + } + + expect( + controller.applyInspectorSource( + bufferId: buffer.id, + revision: buffer.revision, + expectedSource: original, + source: converted, + requiresSourceChange: true, + ), + isTrue, + ); + expect( + controller.state.activeBuffer!.editorState.undoState.undo, + hasLength(1), + ); + verify(controller.state.activeText); + expect(controller.undoActiveBuffer(), isTrue); + expect(controller.state.activeText, original); + expect(controller.redoActiveBuffer(), isTrue); + expect(controller.state.activeText, converted); + verify(controller.state.activeText); + expect(await controller.saveActive(), isTrue); + expect(await controller.closeDocumentBuffer(buffer.id), isTrue); + await controller.openActiveFile(file.path); + expect(controller.state.activeText, await file.readAsString()); + verify(controller.state.activeText); + }, + ); + } + test( + 'inspector pending text is flushed by Save and uses one buffer undo operation', + () async { + final root = await Directory.systemTemp.createTemp( + 'busymark-inspector-save-', + ); + addTearDown(() => root.delete(recursive: true)); + final file = File(p.join(root.path, 't.topic')); + const original = '

    Text

    \n'; + await file.writeAsString(original); + final harness = await _createControllerHarness(); + await harness.settingsController.setAutoSave(false); + final controller = harness.controller._notifier; + await controller.openPath(file.path); + final buffer = controller.state.activeBuffer!; + final draft = controller.propertyEdits.bind('id-target-generation', '', ( + value, + ) { + final visual = BusyMarkWysiwygDocumentController( + document: const WritersideEditingAdapter().parseXml( + filePath: file.path, + source: buffer.text, + )!, + ); + final paragraph = visual.document.blocks + .firstWhere((b) => b.attributes['element'] == 'topic') + .children + .firstWhere((b) => b.kind == BusyBlockKind.paragraph); + final accepted = + visual.updateWritersideProperty(paragraph.id, 'id', value) && + controller.applyInspectorSource( + bufferId: buffer.id, + revision: buffer.revision, + expectedSource: buffer.text, + source: visual.markdown, + ); + visual.dispose(); + return accepted; + }); + controller.propertyEdits.edit(draft, 'intro'); + expect(controller.state.activeText, original); + expect(await controller.saveActive(), isTrue); + expect(await file.readAsString(), contains('id="intro"')); + expect( + controller.state.activeBuffer!.editorState.undoState.undo, + hasLength(1), + ); + expect(controller.undoActiveBuffer(), isTrue); + expect(controller.state.activeText, original); + expect(controller.redoActiveBuffer(), isTrue); + final live = controller.state.activeBuffer!; + expect( + controller.applyInspectorSource( + bufferId: live.id, + revision: live.revision, + expectedSource: live.text, + source: live.text, + requiresSourceChange: true, + ), + isFalse, + reason: + 'An intended semantic change lost by serialization must not succeed', + ); + + expect( + controller.applyInspectorSource( + bufferId: buffer.id, + revision: buffer.revision, + expectedSource: buffer.text, + source: 'WRONG', + ), + isFalse, + ); + expect( + controller.applyInspectorSource( + bufferId: live.id, + revision: live.revision, + expectedSource: live.text, + source: live.text, + ), + isTrue, + ); + expect( + controller.state.activeBuffer!.editorState.undoState.undo, + hasLength(1), + ); + }, + ); + test( + 'invalid property draft cancels Save, close, and view changes without losing text', + () async { + final harness = await _createControllerHarness(); + await harness.settingsController.setAutoSave(false); + final controller = harness.controller._notifier; + await controller.openPath('test/fixtures/markdown/basic.md'); + final buffer = controller.state.activeBuffer!; + final draft = controller.propertyEdits.bind( + 'captured', + 'old', + (_) => false, + ); + controller.propertyEdits.edit(draft, 'invalid'); + expect(await controller.saveActive(), isFalse); + expect( + await controller.closeDocumentBuffer(buffer.id, discard: true), + isFalse, + ); + controller.updateActiveEditorMode(DocumentViewModePreference.source); + expect( + controller.state.activeBuffer!.editorState.mode, + buffer.editorState.mode, + ); + expect(draft.text, 'invalid'); + controller.propertyEdits.revert(draft); + expect(controller.propertyEdits.flush(), isTrue); + }, + ); test( 'XML visual edits use buffer history, save and reopen without Markdown conversion', () async { diff --git a/test/src/writerside_admonition_test.dart b/test/src/writerside_admonition_test.dart index b3c7d66a..be6837d1 100644 --- a/test/src/writerside_admonition_test.dart +++ b/test/src/writerside_admonition_test.dart @@ -236,8 +236,11 @@ Helpful advice. ); await tester.pump(); - expect(find.byTooltip('Admonition'), findsOneWidget); - await tester.tap(find.byTooltip('Admonition')); + expect(find.byTooltip('Writerside'), findsOneWidget); + expect(find.byTooltip('Admonition'), findsNothing); + await tester.tap(find.byTooltip('Writerside')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Admonitions and quotes')); await tester.pumpAndSettle(); await tester.tap(find.text('Warning')); await tester.pumpAndSettle(); diff --git a/test/src/writerside_authoring_test.dart b/test/src/writerside_authoring_test.dart index df5fdaf2..b2fc469c 100644 --- a/test/src/writerside_authoring_test.dart +++ b/test/src/writerside_authoring_test.dart @@ -137,11 +137,12 @@ void main() { 'Operating System': 'Operating System', 'النظام': 'النظام', 'Platform: Choice': 'Platform: Choice', - r'Platform: "Desktop" \ Keys': r'Platform: "Desktop" \ Keys', + r'Platform: "Desktop" \ Keys': r'Platform: "Desktop" \ Keys', "Author's platform": "Author's platform", 'true': 'true', - 'Leading\nline': r'"Leading\nline"', - ' padded ': '" padded "', + 'Leading\nline': 'Leading line', + ' padded ': ' padded ', + 'R&D ': 'R&D <Desktop>', }.entries) { final c = BusyMarkWysiwygDocumentController( document: const MarkdownParser() @@ -202,7 +203,10 @@ Text final c = BusyMarkWysiwygDocumentController(document: document); expect(c.markdown, source); expect(c.updateTopicSwitcherLabel("Author's: \"Desktop\""), isTrue); - expect(c.markdown, contains("switcher-label: Author's: \"Desktop\"")); + expect( + c.markdown, + contains("switcher-label: Author's: "Desktop""), + ); expect(c.markdown, contains(r'custom: "kept\\raw"')); final ordinary = parser .parse( @@ -217,6 +221,33 @@ Text ); }, ); + test('switcher edits preserve CRLF and unrelated front matter bytes', () { + const source = '--- \r\ncustom: "untouched"\r\n---\r\n\r\n# Topic\r\n'; + final c = BusyMarkWysiwygDocumentController( + document: const MarkdownParser() + .parse( + filePath: 't.md', + source: source, + mode: MarkdownMode.writersideMarkdown, + ) + .busyDocument, + ); + expect(c.updateTopicSwitcherLabel('A & "B"'), isTrue); + expect( + c.markdown, + source.replaceFirst( + '--- \r\n', + '--- \r\nswitcher-label: A & "B"\r\n', + ), + ); + expect(c.updateTopicSwitcherLabel('Next'), isTrue); + expect( + c.markdown, + contains('switcher-label: Next\r\ncustom: "untouched"\r\n'), + ); + expect(c.updateTopicSwitcherLabel(''), isTrue); + expect(c.markdown, source); + }); test( 'clearing the topic switcher label restores the default without changing other metadata', () { @@ -230,7 +261,7 @@ Text .busyDocument, ); final original = c.markdown; - expect(c.updateTopicSwitcherLabel(''), isFalse); + expect(c.updateTopicSwitcherLabel(''), isTrue); expect(c.markdown, original); expect(c.updateTopicSwitcherLabel("Author's: Platform"), isTrue); expect(c.updateTopicSwitcherLabel(''), isTrue); @@ -908,7 +939,7 @@ Text c.rebaseCommittedSource(saved); expect(c.blockById(p.id)!.attributes['e:id'], 'other'); c.updateWritersideProperty(p.id, 'id', 'new'); - expect(c.markdown, contains('id="new" e:id="other"')); + expect(c.markdown, contains("id='new' e:id=\"other\"")); }, ); test( diff --git a/test/src/writerside_authoring_ui_test.dart b/test/src/writerside_authoring_ui_test.dart index e805d8d0..88de976a 100644 --- a/test/src/writerside_authoring_ui_test.dart +++ b/test/src/writerside_authoring_ui_test.dart @@ -6,6 +6,7 @@ import 'package:busymark/src/app/app_settings.dart'; import 'package:busymark/src/app/busymark_design.dart'; import 'package:busymark/src/editor/wysiwyg/writerside_editing_adapter.dart'; import 'package:busymark/src/editor/wysiwyg/writerside_properties.dart'; +import 'package:busymark/src/editor/wysiwyg/writerside_inspector_target.dart'; import 'package:busymark/src/editor/wysiwyg/wysiwyg_editor.dart'; import 'package:busymark/src/editor/wysiwyg/wysiwyg_inline_controller.dart'; import 'package:busymark/src/editor/wysiwyg/wysiwyg_session_state.dart'; @@ -14,7 +15,12 @@ import 'package:busymark/src/markdown/markdown_model.dart'; import 'package:busymark/src/markdown/markdown_parser.dart'; import 'package:busymark/src/platform/native_menu_service.dart'; import 'package:busymark/src/writerside/writerside_project.dart'; +import 'package:busymark/src/writerside/writerside_effective_properties.dart'; +import 'package:busymark/src/writerside/writerside_model.dart'; +import 'package:busymark/src/editor/wysiwyg/wysiwyg_document_controller.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/gestures.dart'; +import 'package:busymark/src/editor/wysiwyg/wysiwyg_block_widgets.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -59,7 +65,315 @@ Finder _textField(String text) => _contentFields() .map((e) => find.byWidget(e.widget)) .first; +Future inspectSelection(WidgetTester tester) async { + tester + .state(find.byType(BusyMarkWysiwygEditor)) + .inspectCurrentSelection(); + await tester.pumpAndSettle(); +} + void main() { + testWidgets( + 'navigation selection represents authored declarations after reopening', + (tester) async { + var c = BusyMarkWysiwygDocumentController(document: _document(true)); + addTearDown(() => c.dispose()); + Future> show() async { + final document = c.document; + await tester.pumpWidget( + _app( + BusyMarkWritersideProperties( + document: document, + target: document.blocks.first, + path: [document.blocks.first], + topicSelected: true, + onClose: () {}, + onTargetSelected: (_) {}, + onProperty: (_, _) => true, + onHeadingText: (_) => true, + onTopicProperty: (name, value) => name == '@navigation' + ? value == 'default' + ? c.resetTopicNavigation() + : value == 'none' + ? c.updateTopicNavigation('for', 'none') + : c.resetTopicNavigation() && + c.updateTopicNavigation('for', '') + : c.updateTopicNavigation(name, value), + onManageItem: (_, _, _) {}, + ), + ), + ); + final section = find.byWidgetPredicate( + (w) => + w is TextButton && + w.key == const ValueKey('writerside-disclosure-Topic navigation'), + ); + if (find + .byWidgetPredicate( + (w) => w is BusyMarkComboRow && w.values.contains('all'), + ) + .evaluate() + .isEmpty) { + await tester.ensureVisible(section); + await tester.tap(section); + await tester.pumpAndSettle(); + } + return tester.widget>( + find.byWidgetPredicate( + (w) => w is BusyMarkComboRow && w.values.contains('all'), + ), + ); + } + + for (final option in ['all', 'none', 'default']) { + (await show()).onSelected(option); + final saved = c.markdown; + c.dispose(); + c = BusyMarkWysiwygDocumentController( + document: _document(true, source: saved), + ); + expect((await show()).selected, option, reason: saved); + } + expect(c.updateTopicNavigation('for', 'chapter,def'), isTrue); + final saved = c.markdown; + c.dispose(); + c = BusyMarkWysiwygDocumentController( + document: _document(true, source: saved), + ); + expect((await show()).selected, 'custom'); + }, + ); + testWidgets('definition layout displays module default and reset origin', ( + tester, + ) async { + final c = BusyMarkWysiwygDocumentController( + document: _document( + true, + source: + '

    Body

    ', + ), + ); + addTearDown(c.dispose); + const effective = WritersideEffectiveProperties( + settings: WritersideSettingsConfig( + defaultProperties: [ + WritersideDefaultProperty( + elementName: 'deflist', + propertyName: 'type', + value: 'medium', + ), + ], + ), + ); + Future> show() async { + final document = _document(true, source: c.markdown); + final list = document.blocks.first.children.single; + await tester.pumpWidget( + _app( + BusyMarkWritersideProperties( + document: document, + target: list, + path: [document.blocks.first, list], + topicSelected: false, + effectiveProperties: effective, + onClose: () {}, + onTargetSelected: (_) {}, + onProperty: (name, value) => c.updateWritersideProperty( + c.document.blocks.first.children.single.id, + name, + value, + ), + onHeadingText: (_) => true, + onTopicProperty: (_, _) => true, + onManageItem: (_, _, _) {}, + ), + ), + ); + return tester.widget>( + find.byWidgetPredicate( + (w) => + w is BusyMarkComboRow && w.title == 'List layout (type)', + ), + ); + } + + final layout = await show(); + expect(layout.selected, 'wide'); + expect(layout.labelFor(''), 'Use default'); + layout.onSelected(''); + final reset = await show(); + expect(reset.selected, ''); + expect(reset.labelFor(''), 'Use default'); + expect(c.markdown, isNot(contains('type='))); + expect( + find.byWidgetPredicate( + (w) => w is Text && w.data == 'Default: Medium (1:2) · Module', + ), + findsOneWidget, + ); + expect(reset.tooltip, 'Effective: Medium (1:2) — module default'); + expect( + find.byWidgetPredicate( + (w) => + w is Semantics && + w.properties.hint == 'Effective: Medium (1:2) — module default', + ), + findsOneWidget, + ); + }); + testWidgets('XML title control retains authored absence and child title', ( + tester, + ) async { + final document = _document( + true, + source: + 'Install <b>now</b>

    Body

    ', + ); + final chapter = document.blocks.first.children.single; + String? committedName; + String? committedValue; + await tester.pumpWidget( + _app( + BusyMarkWritersideProperties( + document: document, + target: chapter, + path: [document.blocks.first, chapter], + topicSelected: false, + onClose: () {}, + onTargetSelected: (_) {}, + onProperty: (name, value) { + committedName = name; + committedValue = value; + return true; + }, + onHeadingText: (_) => throw StateError('XML title is an attribute'), + onTopicProperty: (_, _) => true, + onManageItem: (_, _, _) => true, + ), + ), + ); + final field = find.descendant( + of: find.byKey( + ValueKey('writerside-property-a.topic-element-${chapter.id}-title'), + ), + matching: find.byType(TextField), + ); + expect(field, findsOneWidget); + expect(tester.widget(field).controller!.text, isEmpty); + await tester.enterText(field, 'Default title'); + await tester.testTextInput.receiveAction(TextInputAction.done); + expect(committedName, 'title'); + expect(committedValue, 'Default title'); + expect(chapter.children.first.attributes['instance'], 'web'); + expect(chapter.children.first.rawSource, contains('now')); + }); + + testWidgets('inactive published values remain inspectable and removable', ( + tester, + ) async { + final document = _document( + true, + source: + 'code', + ); + final code = document.blocks.first.children.single; + final removed = []; + await tester.pumpWidget( + _app( + BusyMarkWritersideProperties( + document: document, + target: code, + path: [document.blocks.first, code], + topicSelected: false, + onClose: () {}, + onTargetSelected: (_) {}, + onProperty: (name, value) { + if (value == null) removed.add(name); + return true; + }, + onHeadingText: (_) => true, + onTopicProperty: (_, _) => true, + onManageItem: (_, _, _) => true, + ), + ), + ); + final state = tester.widget>( + find.byWidgetPredicate( + (w) => + w is BusyMarkComboRow && + w.title == 'Published initial state (default-state)', + ), + ); + expect(state.enabled, isFalse); + expect(state.selected, 'expanded'); + final title = tester.widget( + find.descendant( + of: find.byKey( + ValueKey( + 'writerside-property-a.topic-element-${code.id}-collapsed-title', + ), + ), + matching: find.byType(TextField), + ), + ); + expect(title.readOnly, isTrue); + expect(title.controller!.text, 'Details'); + final reset = find.byWidgetPredicate( + (w) => w is BusyMarkHeaderIconButton && w.tooltip == 'Use default', + ); + expect(reset, findsNWidgets(2)); + tester.widget(reset.first).onPressed!(); + tester.widget(reset.last).onPressed!(); + expect(removed, ['default-state', 'collapsed-title']); + }); + + testWidgets( + 'reset option describes the default after removing a local override', + (tester) async { + final document = const WritersideEditingAdapter().parseXml( + filePath: 't.topic', + source: + '', + )!; + final code = document.blocks.first.children.single; + await tester.pumpWidget( + _app( + BusyMarkWritersideProperties( + document: document, + target: code, + path: [document.blocks.first, code], + topicSelected: false, + onClose: () {}, + onTargetSelected: (_) {}, + onProperty: (_, _) => true, + onHeadingText: (_) => true, + onTopicProperty: (_, _) => true, + onManageItem: (_, _, _) => true, + ), + ), + ); + await tester.pumpAndSettle(); + final collapse = tester.widget>( + find.byWidgetPredicate( + (w) => + w is BusyMarkComboRow && + w.title == 'Collapsible (collapsible)', + ), + ); + final state = tester.widget>( + find.byWidgetPredicate( + (w) => + w is BusyMarkComboRow && + w.title == 'Published initial state (default-state)', + ), + ); + expect(collapse.selected, 'true'); + expect(collapse.labelFor(''), 'Use default'); + expect(state.selected, 'expanded'); + expect(state.labelFor(''), 'Use default'); + }, + ); + final binding = TestWidgetsFlutterBinding.ensureInitialized(); const channel = MethodChannel(nativeMenuChannelName); final labels = []; @@ -75,20 +389,43 @@ void main() { presentations.add(entries); if (labels.isEmpty) return null; final label = labels.removeAt(0); - final selected = entries.indexWhere((e) => e['label'] == label); + var ordinal = 0; + int selected = -1; + void visit(List nodes) { + for (final node in nodes) { + final index = ordinal++; + if (node['label'] == label) { + expect(node['enabled'], isTrue); + expect( + node['children'], + isNull, + reason: 'Dispatch a leaf, not a group', + ); + selected = index; + } + if (node['children'] case final List children) { + visit(children); + } + } + } + + visit(entries); expect( selected, greaterThanOrEqualTo(0), reason: 'Native menu must offer $label', ); - expect(entries[selected]['enabled'], isTrue); return selected; }); }); tearDown(() { binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, null); }); - Future mount(WidgetTester tester, BusyMarkWysiwygEditor editor) async { + Future mount( + WidgetTester tester, + BusyMarkWysiwygEditor editor, { + bool openProperties = true, + }) async { tester.view.resetPhysicalSize(); tester.view.physicalSize = const Size(1100, 850); tester.view.devicePixelRatio = 1; @@ -96,12 +433,262 @@ void main() { addTearDown(tester.view.resetDevicePixelRatio); await tester.pumpWidget(_app(editor)); await tester.pumpAndSettle(); + if (openProperties) { + final pointer = await tester.createGesture(kind: PointerDeviceKind.mouse); + await pointer.addPointer(location: const Offset(1, 1)); + await pointer.moveTo( + tester.getCenter(find.byType(BusyMarkWysiwygBlockField).first), + ); + await tester.pumpAndSettle(); + final icon = find.byTooltip('Element Properties'); + expect(icon, findsOneWidget); + await pointer.moveTo(tester.getCenter(icon)); + await tester.pumpAndSettle(); + await pointer.down(tester.getCenter(icon)); + await pointer.up(); + await pointer.removePointer(); + await tester.pumpAndSettle(); + } } + testWidgets( + 'selection leaves the inspector closed and hosted language opens the workspace target', + (tester) async { + final doc = _document( + true, + source: + 'print(1);', + ); + var opened = 0; + String? inspected; + var explicit = false; + await mount( + tester, + BusyMarkWysiwygEditor( + document: doc, + onSourceChanged: (_, _) {}, + onInspectElement: + (_, id, _, requested, multiple, {bool revisionRefresh = false}) { + inspected = id; + explicit = requested; + }, + onPropertiesRequested: () => opened++, + ), + openProperties: false, + ); + await tester.tap(_textField('print(1);')); + await tester.pumpAndSettle(); + expect(find.byType(BusyMarkWritersideProperties), findsNothing); + expect(opened, 0); + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyK); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pumpAndSettle(); + expect(opened, 1); + expect(explicit, isTrue); + expect(inspected, doc.blocks.first.children.single.id); + expect(find.byType(BusyMarkWritersideProperties), findsNothing); + }, + ); + testWidgets( + 'shortcut action edits preserve literal content through undo redo and reopen', + (tester) async { + const original = + '

    Press Ctrl+S now.

    '; + var saved = original; + await mount( + tester, + BusyMarkWysiwygEditor( + document: _document(true, source: original), + onSourceChanged: (_, source) => saved = source, + ), + ); + final body = _textField('Press Ctrl+S now.'); + await tester.tap(body); + tester.widget(body).controller!.selection = + const TextSelection.collapsed(offset: 8); + await tester.pumpAndSettle(); + tester + .state(find.byType(BusyMarkWysiwygEditor)) + .inspectCurrentSelection(); + await tester.pumpAndSettle(); + final entry = find.descendant( + of: find.byWidgetPredicate( + (w) => w is BusyMarkGroupedTextEntry && w.label == 'Action ID (key)', + ), + matching: find.byType(TextField), + ); + await tester.enterText(entry, '\$Copy'); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pumpAndSettle(); + expect(saved, contains('key="\$Copy"')); + expect(tester.widget(entry).controller!.text, r'$Copy'); + expect(saved, contains('>Ctrl+S')); + expect(saved, contains('now')); + final changed = saved; + await tester.tap(_textField('Press Ctrl+S now.')); + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyZ); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pumpAndSettle(); + expect(saved, original); + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyZ); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pumpAndSettle(); + expect(saved, changed); + final reopened = _document( + true, + source: saved, + ).blocks.first.children.single; + expect(reopened.plainText, 'Press Ctrl+S now.'); + expect( + busyInlineReferenceRanges(reopened.inlines) + .firstWhere((r) => r.kind == BusyInlineKind.writersideShortcut) + .attributes['key'], + '\$Copy', + ); + }, + ); + for (final xml in [true, false]) { + testWidgets( + '${xml ? "XML" : "Markdown"} inspector edits a complete mixed-format link through undo redo and reopen', + (tester) async { + final original = xml + ? '

    Before Read guide and Other.

    ' + : '# Title\n\nBefore [Read **guide**][ref] and [Other][ref].\n\n[ref]: old.topic "Keep title"\n'; + var saved = original; + await mount( + tester, + BusyMarkWysiwygEditor( + document: _document(xml, source: original), + onSourceChanged: (_, source) => saved = source, + ), + ); + final body = _textField('Before Read guide and Other.'); + await tester.tap(body); + // Inside the unformatted segment, then inside the bold segment: both + // selections expose the same owning link, without changing styling. + for (final caret in [9, 15]) { + tester + .widget(_textField('Before Read guide and Other.')) + .controller! + .selection = TextSelection.collapsed( + offset: caret, + ); + await tester.pumpAndSettle(); + await inspectSelection(tester); + expect( + find.byWidgetPredicate( + (w) => + w is BusyMarkGroupedTextEntry && + w.label == 'Link target (href)', + ), + findsOneWidget, + ); + } + final entry = find.descendant( + of: find.byWidgetPredicate( + (w) => + w is BusyMarkGroupedTextEntry && + w.label == 'Link target (href)', + ), + matching: find.byType(TextField), + ); + await tester.enterText(entry, 'new.topic#details'); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pumpAndSettle(); + expect( + saved, + contains( + xml + ? 'href="new.topic#details"' + : '[Read **guide**](new.topic#details "Keep title")', + ), + ); + expect(saved, contains(xml ? 'Read guide' : 'Read **guide**')); + expect( + saved, + contains(xml ? 'Other' : '[Other][ref]'), + ); + final changed = saved; + await tester.tap(_textField('Before Read guide and Other.')); + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyZ); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pumpAndSettle(); + expect(saved, original); + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyZ); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pumpAndSettle(); + expect(saved, changed); + await tester.runAsync(() async { + final dir = await Directory.systemTemp.createTemp('mixed-link-'); + try { + final file = File('${dir.path}/a.${xml ? 'topic' : 'md'}'); + await file.writeAsString(saved); + final reopened = BusyMarkWysiwygDocumentController( + document: _document(xml, source: await file.readAsString()), + ); + try { + final block = xml + ? reopened.document.blocks.first.children.single + : reopened.document.blocks.firstWhere( + (b) => b.plainText.startsWith('Before'), + ); + final owners = busyInlineReferenceRanges(block.inlines); + expect(owners, hasLength(2)); + expect( + owners.first.end - owners.first.start, + 'Read guide'.length, + ); + expect(owners.first.destination, 'new.topic#details'); + expect( + busyInlineStyleRanges( + block.inlines, + ).where((r) => r.kind == BusyInlineKind.link), + hasLength(3), + ); + final target = WritersideInspectorTarget.fromSelection( + bufferId: 'b', + revision: 2, + source: saved, + document: reopened.document, + blockId: block.id, + inline: owners.first, + ); + final offset = saved.indexOf(xml ? ' insert(WidgetTester tester, String label) async { await tester.pumpAndSettle(); labels.add(label); - await tester.tap(find.byKey(const ValueKey('wysiwyg-writerside-insert'))); + await tester.tap(find.byKey(const ValueKey('wysiwyg-writerside'))); await tester.pumpAndSettle(); } @@ -133,9 +720,7 @@ void main() { } return true; }); - await tester.tap( - find.byKey(const ValueKey('wysiwyg-writerside-insert')), - ); + await tester.tap(find.byKey(const ValueKey('wysiwyg-writerside'))); await tester.pumpAndSettle(); expect(field.focusNode!.hasFocus, isTrue); expect(field.controller!.selection, selection); @@ -194,8 +779,9 @@ void main() { tester.widget(field).controller!.selection = const TextSelection(baseOffset: 0, extentOffset: 4); await tester.pumpAndSettle(); + await inspectSelection(tester); labels.add(label); - await tester.tap(find.byTooltip('Semantic formatting')); + await tester.tap(find.byTooltip('Writerside')); await tester.pumpAndSettle(); final tag = { 'UI Control': 'control', @@ -220,6 +806,192 @@ void main() { ); } } + for (final xml in [true, false]) { + for (final pair in const [ + ('UI Control', 'control'), + ('File / Path', 'path'), + ('UI Path', 'ui-path'), + ('Keyboard Shortcuts', 'shortcut'), + ]) { + testWidgets( + '${xml ? "XML" : "Markdown"} collapsed ${pair.$1} enters text and cancels neutrally', + (tester) async { + final original = _document(xml).source!; + var saved = original; + await mount( + tester, + BusyMarkWysiwygEditor( + document: _document(xml), + onSourceChanged: (_, s) => saved = s, + ), + openProperties: false, + ); + await tester.tap(_textField('Text')); + final body = tester.widget(_textField('Text')); + body.controller!.selection = const TextSelection.collapsed(offset: 2); + await tester.pumpAndSettle(); + labels.add(pair.$1); + await tester.tap(find.byTooltip('Writerside')); + await tester.pumpAndSettle(); + expect(presentations.single.map((e) => e['label']), [ + 'Insert elements', + 'Admonitions and quotes', + 'Semantic text', + ]); + expect(find.byTooltip('Semantic formatting'), findsNothing); + expect(saved, original); + final input = find.descendant( + of: find.byKey(const ValueKey('wysiwyg-semantic-text')), + matching: find.byType(TextField), + ); + expect(input, findsOneWidget); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + expect(saved, original); + expect( + body.controller!.selection, + const TextSelection.collapsed(offset: 2), + ); + labels.add(pair.$1); + await tester.tap(find.byTooltip('Writerside')); + await tester.pumpAndSettle(); + await tester.enterText(input, 'Open & '); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pumpAndSettle(); + expect( + saved, + contains('<${pair.$2}>Open & <Files>'), + ); + expect(_document(xml, source: saved).isXmlTopic, xml); + final authored = saved; + expect( + _document(xml, source: saved).blocks + .expand((b) => xml ? b.children : [b]) + .any((b) => b.plainText == 'TeOpen & xt'), + isTrue, + ); + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyZ); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pumpAndSettle(); + expect(saved, original); + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyZ); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pumpAndSettle(); + expect(saved, authored); + expect( + saved, + isNot(contains('key=')), + reason: 'Literal shortcuts do not invent keymap action IDs', + ); + }, + ); + } + } + for (final xml in [false, true]) { + for (final style in ['Tip', 'Note', 'Warning', 'Quote']) { + testWidgets( + '${xml ? "XML" : "Markdown"} $style keeps editing focus and one-step Undo/Redo', + (tester) async { + final original = xml + ? '

    Before.

    $style example.

    After.

    ' + : '# Title\n\nBefore.\n\n$style **example**.\n\nAfter.\n'; + var source = original; + await mount( + tester, + BusyMarkWysiwygEditor( + document: _document(xml, source: original), + onSourceChanged: (_, value) => source = value, + ), + openProperties: false, + ); + await tester.tap(_textField('$style example.')); + await tester.pumpAndSettle(); + labels.add(style); + await tester.tap(find.byTooltip('Writerside')); + await tester.pumpAndSettle(); + final changed = source; + expect( + changed, + contains( + xml + ? '<${style.toLowerCase()}>' + : style == 'Tip' + ? '> Tip **example**.' + : 'style="${style.toLowerCase()}"', + ), + ); + expect(changed, contains(xml ? 'example' : '**example**')); + expect( + tester + .widget(_textField('$style example.')) + .focusNode! + .hasFocus, + isTrue, + ); + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyZ); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pumpAndSettle(); + expect(source, original); + expect( + tester + .widget(_textField('$style example.')) + .focusNode! + .hasFocus, + isTrue, + ); + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyZ); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pumpAndSettle(); + expect(source, changed); + expect( + tester + .widget(_textField('$style example.')) + .focusNode! + .hasFocus, + isTrue, + ); + }, + ); + } + } + testWidgets('Writerside semantic commands are disabled in literal code', ( + tester, + ) async { + var edits = 0; + await mount( + tester, + BusyMarkWysiwygEditor( + document: _document( + false, + source: '# Topic\n\n```text\nLiteral code\n```\n', + ), + onSourceChanged: (_, _) => edits++, + ), + openProperties: false, + ); + await tester.tap(_textField('Literal code')); + await tester.pumpAndSettle(); + await tester.tap(find.byTooltip('Writerside')); + await tester.pumpAndSettle(); + final semantic = presentations.single.singleWhere( + (e) => e['label'] == 'Semantic text', + ); + expect((semantic['children'] as List).map((e) => e['enabled']), [ + false, + false, + false, + false, + ]); + expect(edits, 0); + }); testWidgets('video insertion draft and cancellation do not modify source', ( tester, ) async { @@ -295,7 +1067,7 @@ void main() { ); await tester.tap(_textField('Text')); labels.addAll(['Include Reusable Content', 'library.topic', 'shared']); - await tester.tap(find.byKey(const ValueKey('wysiwyg-writerside-insert'))); + await tester.tap(find.byKey(const ValueKey('wysiwyg-writerside'))); await tester.pumpAndSettle(); expect(presentations[1].map((e) => e['label']), ['library.topic']); expect(presentations[2].map((e) => e['label']), ['shared']); @@ -342,7 +1114,7 @@ void main() { await tester.tap(_textField('Text')); await tester.pumpAndSettle(); labels.addAll(['Variable Reference', 'product']); - await tester.tap(find.byKey(const ValueKey('wysiwyg-writerside-insert'))); + await tester.tap(find.byKey(const ValueKey('wysiwyg-writerside'))); await tester.pumpAndSettle(); expect(presentations.last.map((e) => e['label']), ['product']); expect(source, contains('%product%')); @@ -369,6 +1141,7 @@ void main() { const TextSelection.collapsed(offset: 2); await tester.pumpAndSettle(); Future selectTopic() async { + await inspectSelection(tester); labels.add('Topic Properties'); await tester.tap( find.byWidgetPredicate( @@ -390,11 +1163,52 @@ void main() { ); expect(saved, isEmpty); Finder entry() => find.descendant( - of: find.byKey( - const ValueKey('writerside-property-topic-switcher-label'), + of: find.byWidgetPredicate( + (w) => + w is BusyMarkGroupedTextEntry && + w.label == 'Switcher label (switcher-label)', ), matching: find.byType(TextField), ); + if (entry().evaluate().isEmpty) { + expect( + tester + .widget( + find.byType(BusyMarkWritersideProperties), + ) + .topicSelected, + isTrue, + reason: tester + .widgetList(find.byType(Text)) + .map((e) => e.data) + .join(' | '), + ); + expect( + find.byKey(const ValueKey('writerside-disclosure-Advanced')), + findsOneWidget, + reason: tester + .widgetList( + find.byType(BusyMarkGroupedTextEntry), + ) + .map((e) => e.label) + .join(', '), + ); + await tester.ensureVisible( + find.byWidgetPredicate( + (w) => + w is TextButton && + w.key == const ValueKey('writerside-disclosure-Advanced'), + ), + ); + await tester.tap( + find.byWidgetPredicate( + (w) => + w is TextButton && + w.key == const ValueKey('writerside-disclosure-Advanced'), + ), + ); + await tester.pumpAndSettle(); + } await tester.enterText(entry(), label); expect(saved, isEmpty); await tester.testTextInput.receiveAction(TextInputAction.done); @@ -410,6 +1224,15 @@ void main() { final authored = saved; await tester.tap(_textField('Text')); await tester.pumpAndSettle(); + expect( + tester + .widget( + find.byType(BusyMarkWritersideProperties), + ) + .topicSelected, + isTrue, + ); + await inspectSelection(tester); expect( tester .widget( @@ -437,11 +1260,46 @@ void main() { await tester.tap(_textField('Text')); await tester.pumpAndSettle(); await selectTopic(); + if (entry().evaluate().isEmpty) { + expect( + tester + .widget( + find.byType(BusyMarkWritersideProperties), + ) + .topicSelected, + isTrue, + reason: tester + .widgetList(find.byType(Text)) + .map((e) => e.data) + .join(' | '), + ); + expect( + find.byKey(const ValueKey('writerside-disclosure-Advanced')), + findsOneWidget, + reason: tester + .widgetList( + find.byType(BusyMarkGroupedTextEntry), + ) + .map((e) => e.label) + .join(', '), + ); + await tester.ensureVisible( + find.byKey(const ValueKey('writerside-disclosure-Advanced')), + ); + await tester.tap( + find.byWidgetPredicate( + (w) => + w is TextButton && + w.key == const ValueKey('writerside-disclosure-Advanced'), + ), + ); + await tester.pumpAndSettle(); + } expect(tester.widget(entry()).controller!.text, label); }, ); } - testWidgets('returning to a table cell retargets Topic Properties', ( + testWidgets('explicit table inspection retargets Topic Properties', ( tester, ) async { var edits = 0; @@ -466,6 +1324,15 @@ void main() { await tester.pumpAndSettle(); await tester.tap(_textField('Cell')); await tester.pumpAndSettle(); + expect( + tester + .widget( + find.byType(BusyMarkWritersideProperties), + ) + .topicSelected, + isTrue, + ); + await inspectSelection(tester); final properties = tester.widget( find.byType(BusyMarkWritersideProperties), ); @@ -492,8 +1359,9 @@ void main() { tester.widget(field).controller!.selection = const TextSelection.collapsed(offset: 2); await tester.pumpAndSettle(); - expect(find.text('Keyboard layout'), findsOneWidget); - expect(find.text('List layout'), findsNothing); + await inspectSelection(tester); + expect(find.text('Keyboard layout (force-layout)'), findsOneWidget); + expect(find.text('List layout (type)'), findsNothing); }); testWidgets( 'chapter properties are available before collapse and local expansion is source-neutral', @@ -512,12 +1380,11 @@ void main() { ); await tester.tap(_textField('Chapter')); await tester.pumpAndSettle(); - expect(find.text('Collapsible'), findsOneWidget); + await inspectSelection(tester); + expect(find.text('Collapsible (collapsible)'), findsOneWidget); expect(source, isEmpty); - final switchRow = find.byWidgetPredicate( - (w) => w is BusyMarkSwitchRow && w.title == 'Collapsible', - ); - await tester.tap(switchRow); + labels.add('Enabled'); + await tester.tap(find.text('Collapsible (collapsible)')); await tester.pumpAndSettle(); expect(source, contains('collapsible="true"')); final before = source; @@ -526,6 +1393,7 @@ void main() { expect(source, before); await tester.tap(_textField('Nested')); await tester.pumpAndSettle(); + await inspectSelection(tester); final props = tester.widget( find.byType(BusyMarkWritersideProperties), ); @@ -556,7 +1424,7 @@ void main() { await tester.pumpWidget(editor('first')); await tester.pumpAndSettle(); await tester.tap(_textField('Text')); - await tester.tap(find.byKey(const ValueKey('wysiwyg-writerside-insert'))); + await tester.tap(find.byKey(const ValueKey('wysiwyg-writerside'))); await tester.pump(); await tester.pumpWidget(editor('second')); await tester.pumpAndSettle(); @@ -583,7 +1451,7 @@ void main() { await tester.pumpWidget(editor('first')); await tester.pumpAndSettle(); await tester.tap(_textField('Text')); - await tester.tap(find.byKey(const ValueKey('wysiwyg-writerside-insert'))); + await tester.tap(find.byKey(const ValueKey('wysiwyg-writerside'))); await tester.pump(); await tester.pumpWidget(editor('second')); await tester.pumpAndSettle(); @@ -683,12 +1551,15 @@ void main() { ); await tester.tap(field); await tester.pumpAndSettle(); + await inspectSelection(tester); final props = tester.widget( find.byType(BusyMarkWritersideProperties), ); expect(props.target!.attributes['id'], 'code'); final entry = find.byKey( - ValueKey('writerside-property-${props.target!.id}-language'), + ValueKey( + 'writerside-property-a.topic-element-${props.target!.id}-language', + ), ); final language = find.descendant( of: entry, @@ -736,8 +1607,11 @@ void main() { onSourceChanged: (_, _) => edits++, ); await mount(tester, editor(first)); + await tester.tap(_textField('print(1);')); + await tester.pumpAndSettle(); + await inspectSelection(tester); final property = find.byKey( - const ValueKey('writerside-property-xml-1-language'), + const ValueKey('writerside-property-a.topic-element-xml-1-language'), ); final entry = find.descendant( of: property, @@ -748,7 +1622,13 @@ void main() { expect(edits, 0); await tester.pumpWidget(_app(editor(replaced))); await tester.pumpAndSettle(); - expect(tester.widget(entry).controller!.text, 'sql'); + expect(tester.widget(entry).controller!.text, 'python'); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pumpAndSettle(); + expect( + find.textContaining('document or element changed'), + findsOneWidget, + ); expect(edits, 0); }, ); @@ -775,13 +1655,33 @@ void main() { await tester.pumpAndSettle(); expect(find.text('Writerside'), findsNothing); expect( - find.byKey(const ValueKey('wysiwyg-writerside-insert')), + find.byKey(const ValueKey('wysiwyg-writerside')), findsOneWidget, ); expect( - find.byKey(const ValueKey('wysiwyg-writerside-semantic')), + find.byKey(const ValueKey('wysiwyg-writerside')), findsOneWidget, ); + expect( + find.byKey(const ValueKey('wysiwyg-writerside-properties')), + findsNothing, + ); + final pointer = await tester.createGesture( + kind: PointerDeviceKind.mouse, + ); + await pointer.addPointer(location: const Offset(1, 1)); + await pointer.moveTo( + tester.getCenter(find.byType(BusyMarkWysiwygBlockField).first), + ); + await tester.pumpAndSettle(); + final icon = find.byTooltip('Element Properties'); + expect(icon, findsOneWidget); + await pointer.moveTo(tester.getCenter(icon)); + await tester.pumpAndSettle(); + await pointer.down(tester.getCenter(icon)); + await pointer.up(); + await pointer.removePointer(); + await tester.pumpAndSettle(); expect(find.byType(BusyMarkWritersideProperties), findsOneWidget); expect(tester.takeException(), isNull); }, diff --git a/test/src/writerside_capitalization_test.dart b/test/src/writerside_capitalization_test.dart new file mode 100644 index 00000000..10e31531 --- /dev/null +++ b/test/src/writerside_capitalization_test.dart @@ -0,0 +1,201 @@ +import 'dart:io'; +import 'package:busymark/src/writerside/writerside_module_service.dart'; +import 'package:busymark/src/writerside/writerside_document_resolver.dart'; +import 'package:busymark/src/editor/wysiwyg/wysiwyg_document_controller.dart'; +import 'package:busymark/src/markdown/markdown_model.dart'; +import 'package:busymark/src/markdown/markdown_parser.dart'; +import 'package:busymark/src/writerside/writerside_effective_properties.dart'; +import 'package:busymark/src/writerside/writerside_model.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test( + 'configured capitalization uses existing variable resolution and source owners', + () async { + final root = await Directory.systemTemp.createTemp( + 'busymark-caps-scope-', + ); + addTearDown(() => root.delete(recursive: true)); + await Directory('${root.path}/topics').create(); + await File('${root.path}/writerside.cfg').writeAsString( + '', + ); + await File( + '${root.path}/v.list', + ).writeAsString(''); + await File('${root.path}/web.tree').writeAsString( + '', + ); + const source = + '

    Body

    Body

    '; + await File('${root.path}/topics/main.topic').writeAsString(source); + final module = await const WritersideModuleService().load(root.path); + final topic = module.topicByReference('main.topic')!; + final context = WritersideResolveContext( + module: module, + topic: topic, + instance: module.instances.single, + ); + final occurrences = const WritersideDocumentResolver() + .resolvePropertyOccurrences(topic.document, context); + final first = occurrences[source.indexOf(' e.name == 'chapter') + .where((e) => e.attributes['id'] == 'module') + .every((e) => !e.attributes.containsKey('caps')), + isTrue, + ); + }, + ); + const rule = WritersideCapsRule(style: 'title', target: 'chapter'); + test( + 'omitted capitalization has the published as-written built-in value', + () { + final p = const WritersideEffectiveProperties().resolve( + 'chapter', + 'caps', + {}, + ); + expect(p.authored, isNull); + expect(p.value, 'aswritten'); + expect(p.origin, WritersidePropertyOrigin.builtIn); + expect(p.limitation, isNull); + }, + ); + for (final target in ['chapter', 'topic', 'toc-element']) { + test( + '$target rule is retained with the verified ignored-rule limitation', + () { + final r = WritersideEffectiveProperties( + settings: WritersideSettingsConfig( + capsRules: [ + WritersideCapsRule(style: 'title', target: target), + WritersideCapsRule(style: 'sentence', target: target), + ], + ), + ); + final p = r.resolve(target, 'caps', {}); + expect(p.value, 'aswritten'); + expect(p.origin, WritersidePropertyOrigin.builtIn); + expect(p.limitation, 'module-caps-ignored'); + final other = r.resolve( + target == 'chapter' ? 'topic' : 'chapter', + 'caps', + {}, + ); + expect(other.value, 'aswritten'); + expect(other.limitation, isNull); + }, + ); + } + test( + 'generic defaults apply; the first declaration wins for capitalization', + () { + const r = WritersideEffectiveProperties( + settings: WritersideSettingsConfig( + capsRules: [rule], + defaultProperties: [ + WritersideDefaultProperty( + elementName: 'chapter', + propertyName: 'caps', + value: 'title', + ), + WritersideDefaultProperty( + elementName: 'chapter', + propertyName: 'caps', + value: 'sentence', + ), + WritersideDefaultProperty( + elementName: 'topic', + propertyName: 'caps', + value: 'upper', + ), + ], + ), + ); + final p = r.resolve('chapter', 'caps', {}); + expect(p.value, 'title'); + expect(p.origin, WritersidePropertyOrigin.module); + expect(p.limitation, 'module-caps-ignored'); + final local = r.resolve('chapter', 'caps', {'caps': 'aswritten'}); + expect(local.value, 'aswritten'); + expect(local.origin, WritersidePropertyOrigin.element); + expect(local.limitation, isNull); + // A chapter default never leaks into an unrelated element. + expect(r.resolve('tab', 'caps', {}).value, 'aswritten'); + }, + ); + test( + 'variable-valued local and generic capitalization use existing resolution', + () { + const r = WritersideEffectiveProperties( + variables: {'case': 'title'}, + settings: WritersideSettingsConfig( + capsRules: [rule], + defaultProperties: [ + WritersideDefaultProperty( + elementName: 'chapter', + propertyName: 'caps', + value: '%case%', + ), + ], + ), + ); + expect(r.resolve('chapter', 'caps', {}).value, 'title'); + expect( + r.resolve('chapter', 'caps', {}).origin, + WritersidePropertyOrigin.module, + ); + expect( + r.resolve('chapter', 'caps', {'caps': '%case%'}).origin, + WritersidePropertyOrigin.element, + ); + final missing = r.resolve('chapter', 'caps', {'caps': '%unknown%'}); + expect(missing.value, isNull); + expect(missing.origin, WritersidePropertyOrigin.unresolved); + expect(missing.authored, '%unknown%'); + }, + ); + test( + 'capitalization edits/reset keep heading text, inline formatting and source owners', + () { + const source = + '# Topic\n\n## a journey through HTTP and APIs\n\nBody **formatting**.\n'; + final document = const MarkdownParser() + .parse( + filePath: 'caps.md', + source: source, + mode: MarkdownMode.writersideMarkdown, + ) + .busyDocument; + final c = BusyMarkWysiwygDocumentController(document: document); + addTearDown(c.dispose); + final heading = c.document.blocks[1]; + expect(c.updateWritersideProperty(heading.id, 'caps', 'upper'), isTrue); + expect( + c.markdown, + contains('## a journey through HTTP and APIs {caps="upper"}'), + ); + expect(c.markdown, endsWith('Body **formatting**.\n')); + expect(c.updateWritersideProperty(heading.id, 'caps', null), isTrue); + expect(c.markdown, source); + }, + ); +} diff --git a/test/src/writerside_code_source_picker_test.dart b/test/src/writerside_code_source_picker_test.dart new file mode 100644 index 00000000..9eaf21f3 --- /dev/null +++ b/test/src/writerside_code_source_picker_test.dart @@ -0,0 +1,72 @@ +import 'dart:io'; +import 'package:busymark/src/writerside/writerside_source_loader.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; + +void main() { + test( + 'chooser references resolve snippets, topic paths and shadowing safely', + () async { + final root = await Directory.systemTemp.createTemp('code-source-picker-'); + addTearDown(() => root.delete(recursive: true)); + for (final dir in ['topics', 'snippets', 'samples']) { + await Directory(p.join(root.path, dir)).create(); + } + final topic = p.join(root.path, 'topics/a.topic'); + await File(topic).writeAsString(''); + Future select(String name) async { + final file = File(p.join(root.path, name)); + await file.writeAsString('code'); + return const WritersideSourceLoader().referenceForFile( + selectedPath: file.path, + documentPath: topic, + workspaceRoot: root.path, + snippetsDirectory: 'snippets', + ); + } + + expect(await select('snippets/sample.dart'), 'sample.dart'); + expect( + await select('samples/Café sample.dart'), + '../samples/Café sample.dart', + ); + expect(await select('topics/local.dart'), 'local.dart'); + await File( + p.join(root.path, 'topics/sample.dart'), + ).writeAsString('other'); + expect(await select('snippets/sample.dart'), '../snippets/sample.dart'); + expect( + await const WritersideSourceLoader().referenceForFile( + selectedPath: root.path, + documentPath: topic, + workspaceRoot: root.path, + ), + isNull, + ); + final outside = await File( + p.join(root.parent.path, '${p.basename(root.path)}-outside'), + ).writeAsString('outside'); + addTearDown(outside.delete); + expect( + await const WritersideSourceLoader().referenceForFile( + selectedPath: outside.path, + documentPath: topic, + workspaceRoot: root.path, + ), + isNull, + ); + final link = await Link( + p.join(root.path, 'snippets/escape'), + ).create(outside.path); + expect( + await const WritersideSourceLoader().referenceForFile( + selectedPath: link.path, + documentPath: topic, + workspaceRoot: root.path, + snippetsDirectory: 'snippets', + ), + isNull, + ); + }, + ); +} diff --git a/test/src/writerside_document_test.dart b/test/src/writerside_document_test.dart index 57d62821..f10aad7b 100644 --- a/test/src/writerside_document_test.dart +++ b/test/src/writerside_document_test.dart @@ -34,7 +34,9 @@ void main() { validateLocalReferences: false, ); expect( - markdown.busyDocument.blocks.any((b) => b.isSourceOnly), + markdown.busyDocument.blocks.any( + (b) => b.isSourceOnly || b.isSourceProtected, + ), protectedSource, ); final document = const WritersideDocumentParser().parseMarkdown( diff --git a/test/src/writerside_identity_test.dart b/test/src/writerside_identity_test.dart index 9480b484..8fcb53e9 100644 --- a/test/src/writerside_identity_test.dart +++ b/test/src/writerside_identity_test.dart @@ -97,6 +97,32 @@ void main() { expect(index.safeRenameEdits(local, 'caption'), hasLength(3)); }); + test( + 'checkbox list eligibility reaches diagnostics without admitting arbitrary styles', + () { + final module = project.activeModule!; + for (final type in ['checkbox', 'invented']) { + final topic = const WritersideTopicParser().parseXml( + filePath: p.join(directory.path, 'topics/lists.topic'), + topicsRoot: p.join(directory.path, 'topics'), + source: + '
  • Done

  • ', + ); + final index = WritersideProjectIndex.build([ + module.copyWith(topics: [topic]), + ]); + expect( + index.diagnostics.where((d) => d.args['attribute'] == 'type').isEmpty, + type == 'checkbox', + ); + expect( + index.diagnostics.where((d) => d.args['attribute'] == 'checked'), + isEmpty, + ); + } + }, + ); + test('completion filters context and target identity before limiting', () { List suggest(String text, {int limit = 40}) => const SourceAutocompleteProvider() diff --git a/test/src/writerside_inspector_controls_test.dart b/test/src/writerside_inspector_controls_test.dart new file mode 100644 index 00000000..7ec12fe4 --- /dev/null +++ b/test/src/writerside_inspector_controls_test.dart @@ -0,0 +1,488 @@ +import 'package:flutter/services.dart'; +import 'package:busymark/src/app/busymark_glyphs.dart'; +import 'dart:async'; +import 'dart:io'; +import 'package:busymark/l10n/generated/app_localizations.dart'; +import 'package:busymark/src/app/busymark_design.dart'; +import 'package:busymark/src/editor/wysiwyg/writerside_editing_adapter.dart'; +import 'package:busymark/src/editor/wysiwyg/writerside_properties.dart'; +import 'package:busymark/src/editor/wysiwyg/writerside_property_session.dart'; +import 'package:busymark/src/editor/wysiwyg/wysiwyg_document_controller.dart'; +import 'package:busymark/src/editor/wysiwyg/wysiwyg_inline_controller.dart'; +import 'package:busymark/src/markdown/busymark_document.dart'; +import 'package:busymark/src/markdown/markdown_model.dart'; +import 'package:busymark/src/markdown/markdown_parser.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +BusyDocument parse(String source, bool xml) => xml + ? const WritersideEditingAdapter().parseXml( + filePath: 'a.topic', + source: source, + )! + : const MarkdownParser() + .parse( + filePath: 'a.md', + source: source, + mode: MarkdownMode.writersideMarkdown, + validateLocalReferences: false, + ) + .busyDocument; +Iterable walk(List blocks) sync* { + for (final b in blocks) { + yield b; + yield* walk(b.children); + } +} + +Widget app(Widget child) => MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: Align( + alignment: Alignment.centerRight, + child: SizedBox(width: 390, child: child), + ), + ), +); +Finder field(String label) => find.descendant( + of: find.byWidgetPredicate( + (w) => w is BusyMarkGroupedTextEntry && w.label == label, + ), + matching: find.byType(TextField), +); +Future edit(WidgetTester tester, String label, String value) async { + await tester.ensureVisible(field(label)); + await tester.enterText(field(label), value); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pumpAndSettle(); +} + +void main() { + testWidgets( + 'focused property Undo and Redo use document commands and reject invalid drafts', + (tester) async { + final doc = parse( + '

    Text

    ', + true, + ); + final session = WritersidePropertySession(); + addTearDown(session.dispose); + var undo = 0, redo = 0; + await tester.pumpWidget( + app( + BusyMarkWritersideProperties( + document: doc, + path: [doc.blocks.first, doc.blocks.first.children.single], + target: doc.blocks.first.children.single, + topicSelected: false, + session: session, + onClose: () {}, + onTargetSelected: (_) {}, + onHeadingText: (_) => false, + onTopicProperty: (_, _) => false, + onManageItem: (_, _, _) {}, + onProperty: (_, _) => false, + onUndo: () => undo++, + onRedo: () => redo++, + ), + ), + ); + await tester.tap(field('Element anchor (id)')); + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyZ); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pumpAndSettle(); + expect(undo, 1); + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyZ); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pumpAndSettle(); + expect(redo, 1); + await tester.enterText(field('Element anchor (id)'), 'invalid id'); + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyZ); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pumpAndSettle(); + expect(undo, 1); + expect(session.hasPending, isTrue); + expect( + tester.widget(field('Element anchor (id)')).controller!.text, + 'invalid id', + ); + }, + ); + + for (final xml in [true, false]) { + testWidgets( + '${xml ? "XML" : "Markdown"} inspector edits the named reference attributes and reopens them', + (tester) async { + final original = xml + ? '

    Before Read guide after.

    Logo

    Press Ctrl+S.

    ' + : '# Title\n\nBefore [Read **guide**](old.topic){origin="old"} after.\n\n![Logo](logo.png){preview-src="old.png"}\n\nPress Ctrl+S.\n\n\n'; + var c = BusyMarkWysiwygDocumentController( + document: parse(original, xml), + ); + addTearDown(() => c.dispose()); + final session = WritersidePropertySession(); + addTearDown(session.dispose); + BusyInlineKind? kind; + BusyBlock selected() => walk(c.document.blocks).firstWhere( + (b) => kind == null + ? b.attributes['element'] == 'include' + : busyInlineReferenceRanges(b.inlines).any((r) => r.kind == kind), + ); + Future show(BusyInlineKind? next) async { + session.transition(); + kind = next; + await tester.pumpWidget( + app( + StatefulBuilder( + builder: (context, refresh) { + final block = selected(); + final inline = kind == null + ? null + : busyInlineReferenceRanges( + block.inlines, + ).firstWhere((r) => r.kind == kind); + bool apply(bool Function() change) { + final result = change(); + if (result) refresh(() {}); + return result; + } + + return BusyMarkWritersideProperties( + document: c.document, + path: c.elementPath(block.id), + target: block, + topicSelected: false, + session: session, + inlineTarget: inline, + onClose: () {}, + onTargetSelected: (_) {}, + onHeadingText: (_) => false, + onTopicProperty: (_, _) => false, + onManageItem: (_, _, _) {}, + onProperty: (name, value) => apply( + () => c.updateWritersideProperty(block.id, name, value), + ), + onInlineProperty: (name, value) => apply( + () => c.updateInlineReference( + block.id, + inline!, + name, + value, + ), + ), + ); + }, + ), + ), + ); + await tester.pumpAndSettle(); + expect( + find.byWidgetPredicate( + (w) => w is BusyMarkComboRow && w.title == 'Element', + ), + findsOneWidget, + ); + expect( + find.text('Select a single element to inspect its properties.'), + findsNothing, + ); + } + + await show(null); + await edit(tester, 'Included element (element-id)', 'new-fragment'); + expect(selected().attributes['id'], 'own'); + await edit(tester, 'Topic (from)', 'new.topic'); + await edit(tester, 'Help module (origin)', 'new-module'); + await show(BusyInlineKind.image); + await edit(tester, 'Preview image (preview-src)', 'new-preview.gif'); + expect( + busyInlineReferenceRanges(selected().inlines).single.destination, + 'logo.png', + ); + await show(BusyInlineKind.writersideShortcut); + await edit(tester, 'Action ID (key)', '\$Copy'); + expect(selected().plainText, 'Press Ctrl+S.'); + await edit( + tester, + 'Keymap help instance (from-keymap-of)', + 'other-instance', + ); + await show(BusyInlineKind.link); + await edit(tester, 'Link target (href)', 'new.topic#anchor'); + await edit(tester, 'Help module (origin)', 'new-module'); + expect(selected().plainText, 'Before Read guide after.'); + await tester.runAsync(() async { + final directory = await Directory.systemTemp.createTemp( + 'inspector-controls-', + ); + addTearDown(() => directory.delete(recursive: true)); + final file = File('${directory.path}/a.${xml ? 'topic' : 'md'}'); + await file.writeAsString(c.markdown); + expect( + c.markdown, + contains(xml ? 'Read guide' : 'Read **guide**'), + ); + c.dispose(); + c = BusyMarkWysiwygDocumentController( + document: parse(await file.readAsString(), xml), + ); + }); + await show(null); + expect(selected().attributes, containsPair('id', 'own')); + expect( + selected().attributes, + containsPair('element-id', 'new-fragment'), + ); + expect(selected().attributes, containsPair('from', 'new.topic')); + await show(BusyInlineKind.image); + final image = busyInlineReferenceRanges(selected().inlines).single; + expect(image.destination, 'logo.png'); + expect(image.attributes['preview-src'], 'new-preview.gif'); + await show(BusyInlineKind.writersideShortcut); + final shortcut = busyInlineReferenceRanges(selected().inlines).single; + expect(shortcut.attributes['key'], '\$Copy'); + expect(shortcut.attributes['from-keymap-of'], 'other-instance'); + expect(selected().plainText, 'Press Ctrl+S.'); + await show(BusyInlineKind.link); + final link = busyInlineReferenceRanges( + selected().inlines, + ).firstWhere((r) => r.kind == BusyInlineKind.link); + expect(link.destination, 'new.topic#anchor'); + expect(link.attributes['origin'], 'new-module'); + expect(tester.takeException(), isNull); + }, + ); + } + testWidgets( + 'one source-file field handles chooser cancellation, selection and removal', + (tester) async { + var c = BusyMarkWysiwygDocumentController( + document: parse( + '', + true, + ), + ); + addTearDown(() => c.dispose()); + final session = WritersidePropertySession(); + addTearDown(session.dispose); + var choice = Completer(); + late StateSetter redraw; + await tester.pumpWidget( + app( + StatefulBuilder( + builder: (context, refresh) { + redraw = refresh; + final code = c.document.blocks.first.children.single; + return BusyMarkWritersideProperties( + document: c.document, + path: [c.document.blocks.first, code], + target: code, + topicSelected: false, + session: session, + onChooseCodeSource: () => choice.future, + onClose: () {}, + onTargetSelected: (_) {}, + onHeadingText: (_) => false, + onTopicProperty: (_, _) => false, + onManageItem: (_, _, _) {}, + onProperty: (name, value) { + final result = c.updateWritersideProperty( + code.id, + name, + value, + ); + if (result) refresh(() {}); + return result; + }, + ); + }, + ), + ), + ); + expect(field('Source file (src)'), findsOneWidget); + expect( + find.byWidgetPredicate( + (w) => w is BusyMarkComboRow && w.values.contains('file'), + ), + findsNothing, + ); + final initial = c.markdown; + await tester.tap(find.byTooltip('Open')); + await tester.pump(); + choice.complete(null); + await tester.pumpAndSettle(); + expect(c.markdown, initial); + choice = Completer(); + await tester.tap(find.byTooltip('Open')); + await tester.pump(); + redraw(() {}); + await tester.pump(); + choice.complete('../snippets/sample.dart'); + await tester.pumpAndSettle(); + expect(c.markdown, contains('src="../snippets/sample.dart"')); + await tester.tap(find.byTooltip('Remove')); + await tester.pumpAndSettle(); + expect(c.markdown, initial); + await edit(tester, 'Source file (src)', 'sample.dart'); + expect(c.markdown, contains('src="sample.dart"')); + expect(c.document.blocks.first.children.single.plainText, isEmpty); + final code = c.document.blocks.first.children.single; + c.updateBlockText(code.id, 'Authored code'); + redraw(() {}); + await tester.pumpAndSettle(); + await edit(tester, 'Source file (src)', 'another.dart'); + expect( + c.document.blocks.first.children.single.plainText, + 'Authored code', + ); + expect(c.markdown, contains('src="sample.dart"')); + expect(session.hasPending, isTrue); + session.revertAll(); + await tester.pumpAndSettle(); + await tester.tap(find.byTooltip('Remove')); + await tester.pumpAndSettle(); + expect( + c.document.blocks.first.children.single.plainText, + 'Authored code', + ); + expect(c.markdown, isNot(contains('src='))); + }, + ); + testWidgets( + 'delayed file choice keeps its captured mutation after a target replacement', + (tester) async { + var c = BusyMarkWysiwygDocumentController( + document: parse( + '', + true, + ), + ); + addTearDown(() => c.dispose()); + var generation = 0; + final choice = Completer(); + final session = WritersidePropertySession(); + addTearDown(session.dispose); + late StateSetter redraw; + await tester.pumpWidget( + app( + StatefulBuilder( + builder: (context, refresh) { + redraw = refresh; + final captured = generation; + final code = c.document.blocks.first.children.single; + return BusyMarkWritersideProperties( + document: c.document, + path: [c.document.blocks.first, code], + target: code, + topicSelected: false, + session: session, + onChooseCodeSource: () => choice.future, + onClose: () {}, + onTargetSelected: (_) {}, + onHeadingText: (_) => false, + onTopicProperty: (_, _) => false, + onManageItem: (_, _, _) {}, + onProperty: (name, value) => captured != generation + ? session.reject(WritersidePropertyRejection.stale) + : c.updateWritersideProperty(code.id, name, value), + ); + }, + ), + ), + ); + await tester.tap(find.byTooltip('Open')); + await tester.pump(); + c.dispose(); + c = BusyMarkWysiwygDocumentController( + document: parse( + '', + true, + ), + ); + generation++; + redraw(() {}); + await tester.pump(); + final replacement = c.markdown; + choice.complete('old-target.dart'); + await tester.pumpAndSettle(); + expect(c.markdown, replacement); + expect(session.feedback, WritersidePropertyRejection.stale); + expect(session.hasPending, isTrue); + }, + ); + testWidgets( + 'Show in Source and target/close actions validate pending drafts', + (tester) async { + final doc = parse( + '

    Text

    ', + true, + ); + final session = WritersidePropertySession(); + addTearDown(session.dispose); + var navigation = 0, closes = 0, targets = 0; + await tester.pumpWidget( + app( + BusyMarkWritersideProperties( + document: doc, + path: doc.blocks, + target: doc.blocks.first, + topicSelected: true, + session: session, + onSourceLocation: () => navigation++, + onClose: () => closes++, + onTargetSelected: (_) => targets++, + onProperty: (_, _) => false, + onHeadingText: (_) => false, + onTopicProperty: (_, _) => false, + onManageItem: (_, _, _) {}, + ), + ), + ); + final button = find.byWidgetPredicate( + (w) => w is BusyMarkDialogButton && w.label == 'Show in Source', + ); + expect(button, findsOneWidget); + expect( + tester.widget(button).icon, + BusyMarkGlyphs.sourceView, + ); + expect( + find.ancestor(of: button, matching: find.byType(BusyMarkGroupedList)), + findsNothing, + ); + expect( + tester.getSize(button).width, + lessThan( + tester.getSize(find.byType(BusyMarkWritersideProperties)).width, + ), + ); + await tester.enterText(field('Default title (title)'), 'rejected draft'); + await tester.ensureVisible(find.text('Show in Source')); + await tester.tap(find.text('Show in Source')); + await tester.pumpAndSettle(); + await tester.tap(find.byTooltip('Close')); + await tester.pumpAndSettle(); + tester + .widget>( + find.byType(BusyMarkComboRow).first, + ) + .onSelected('@topic'); + expect(navigation, 0); + expect(closes, 0); + expect(targets, 0); + expect(session.hasPending, isTrue); + session.revertAll(); + await tester.pumpAndSettle(); + await tester.tap(find.text('Show in Source')); + await tester.pumpAndSettle(); + expect(navigation, 1); + expect(doc.source, contains('id="a"')); + }, + ); +} diff --git a/test/src/writerside_inspector_session_test.dart b/test/src/writerside_inspector_session_test.dart new file mode 100644 index 00000000..7981a360 --- /dev/null +++ b/test/src/writerside_inspector_session_test.dart @@ -0,0 +1,1564 @@ +import 'package:busymark/src/editor/wysiwyg/writerside_editing_adapter.dart'; +import 'package:busymark/src/editor/wysiwyg/wysiwyg_document_controller.dart'; +import 'package:busymark/src/editor/wysiwyg/wysiwyg_inline_controller.dart'; +import 'package:busymark/src/editor/wysiwyg/writerside_inspector_target.dart'; +import 'package:busymark/src/editor/wysiwyg/writerside_property_session.dart'; +import 'package:busymark/src/markdown/markdown_model.dart'; +import 'package:busymark/src/markdown/busymark_document.dart'; +import 'package:busymark/src/markdown/markdown_parser.dart'; +import 'package:busymark/src/writerside/writerside_effective_properties.dart'; +import 'package:busymark/src/writerside/writerside_model.dart'; +import 'package:busymark/src/writerside/writerside_property_validation.dart'; +import 'package:busymark/src/workspace/document_buffer.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('only an established history traversal identifies a restored owner', () { + const original = DocumentHistoryState( + text: 'Original owner', + selection: TextSelection.collapsed(offset: 0), + ); + const deleted = DocumentHistoryState( + text: '', + selection: TextSelection.collapsed(offset: 0), + ); + final history = const DocumentUndoState().push(original); + final undone = history.afterUndo(deleted); + expect(undone.restoredEntryFrom(history), same(original)); + final redone = undone.afterRedo(original); + expect(redone.restoredEntryFrom(undone), same(deleted)); + // A normal edit can clear a single redo entry and append the current state, + // producing the same stack sizes as Redo. It is still a new authored owner. + final typedReplacement = undone.push(original); + expect(typedReplacement.restoredEntryFrom(undone), isNull); + final grouped = history.push(deleted, group: 'typing'); + expect(grouped.restoredEntryFrom(history), isNull); + expect( + grouped.push(original, group: 'typing').restoredEntryFrom(grouped), + isNull, + ); + }); + for (final xml in [true, false]) { + test( + '${xml ? "XML" : "Markdown"} invalidation survives compatible replacement', + () { + BusyDocument parse(String source) => const MarkdownParser() + .parse( + filePath: xml ? 't.topic' : 't.md', + source: source, + mode: MarkdownMode.writersideMarkdown, + validateLocalReferences: false, + ) + .busyDocument; + final original = xml + ? '

    Original owner

    ' + : '# Topic\n\nOriginal owner\n'; + final document = parse(original); + final target = WritersideInspectorTarget.fromSource( + bufferId: 'b', + revision: 1, + source: original, + document: document, + selection: TextSelection.collapsed( + offset: original.indexOf('Original owner'), + ), + ); + final unavailable = target.markUnavailable(); + final replacement = original.replaceFirst( + 'Original owner', + 'Replacement owner', + ); + expect(unavailable.matches('b', 1, original), isFalse); + for (final source in [replacement, original]) { + expect( + unavailable.refreshUnchangedElement( + revision: 2, + source: source, + document: parse(source), + ), + isNull, + ); + } + expect( + WritersideInspectorTarget.fromSource( + bufferId: 'b', + revision: 2, + source: replacement, + document: parse(replacement), + selection: TextSelection.collapsed( + offset: replacement.indexOf('Replacement owner'), + ), + ).block?.plainText, + 'Replacement owner', + ); + // The retained history snapshot remains valid for genuine restoration. + expect( + target + .refreshUnchangedElement( + revision: 3, + source: original, + document: parse(original), + ) + ?.block + ?.plainText, + 'Original owner', + ); + }, + ); + } + for (final body in [ + '- Parent %product%\n - Child %product% and %product%\n', + '- Parent\n\n continuation %product% and %product%\n\n - Child %product%\n', + '> - Parent %product%\n> - Child %product% and %product%\n', + '- Parent\n - [Same](t.topic){id="one"} [Same](t.topic){id="two"}\n', + '- Parent\n - ![Same](one.svg) ![Same](one.svg)\n', + '- Parent\n - Copy Copy\n', + '- Parent\n\n

    %product%Image%product%%product%
    %product%

    \n', + ]) { + test('native-list reference owners and projected boundaries: $body', () { + BusyDocument parse(String source) => const MarkdownParser() + .parse( + filePath: 't.md', + source: source, + mode: MarkdownMode.writersideMarkdown, + validateLocalReferences: false, + ) + .busyDocument; + Iterable walk(List blocks) sync* { + for (final b in blocks) { + yield b; + yield* walk(b.children); + } + } + + void verify(String source) { + final c = BusyMarkWysiwygDocumentController(document: parse(source)); + try { + var count = 0; + for (final block in walk(c.document.blocks)) { + for (final range in busyInlineReferenceRanges(block.inlines)) { + if (!{ + BusyInlineKind.link, + BusyInlineKind.image, + BusyInlineKind.writersideVariable, + BusyInlineKind.writersideShortcut, + }.contains(range.kind)) { + continue; + } + count++; + final editor = WritersideInspectorTarget.fromSelection( + bufferId: 'b', + revision: 1, + source: source, + document: c.document, + blockId: block.id, + inline: range, + ); + final offset = editor.sourceLocation; + expect( + offset, + isNotNull, + reason: '${block.plainText}: ${range.kind} ${range.start}', + ); + final fromSource = WritersideInspectorTarget.fromSource( + bufferId: 'b', + revision: 1, + source: source, + document: c.document, + selection: TextSelection.collapsed(offset: offset! + 1), + ); + expect(fromSource.block?.id, block.id); + expect(fromSource.inline?.kind, range.kind); + expect(fromSource.inline?.start, range.start); + expect(fromSource.inline?.referenceIndex, range.referenceIndex); + expect(fromSource.sourceLocation, offset); + if (range.attributes['id'] case final attribute?) { + final inside = source.indexOf('id="$attribute"', offset); + final target = WritersideInspectorTarget.fromSource( + bufferId: 'b', + revision: 1, + source: source, + document: c.document, + selection: TextSelection.collapsed(offset: inside + 4), + ); + expect(target.inline?.start, range.start); + } + } + } + expect(count, greaterThanOrEqualTo(2)); + } finally { + c.dispose(); + } + } + + final original = '# Topic\n\n$body'; + verify(original); + verify(original.replaceAll(' ', ' ')); + verify( + original.replaceAll( + 'alt="Image"', + 'alt="Much longer alternative text"', + ), + ); + if (original.contains('%product%')) { + verify( + original.replaceFirst('%product%', '%a_much_longer_product_name%'), + ); + } + final c = BusyMarkWysiwygDocumentController(document: parse(original)); + addTearDown(c.dispose); + final block = walk( + c.document.blocks, + ).lastWhere((b) => busyInlineReferenceRanges(b.inlines).isNotEmpty); + final reference = busyInlineReferenceRanges(block.inlines).last; + final (attribute, value) = switch (reference.kind) { + BusyInlineKind.writersideVariable => ('reference', 'long_product_name'), + BusyInlineKind.writersideShortcut => ('key', r'$VeryLongAction'), + BusyInlineKind.link => ('href', 'other.topic'), + _ => ('src', 'other.svg'), + }; + final originalTarget = WritersideInspectorTarget.fromSelection( + bufferId: 'b', + revision: 1, + source: original, + document: c.document, + blockId: block.id, + inline: reference, + ); + final authoredBegin = originalTarget.sourceLocation!; + expect( + c.updateInlineReference(block.id, reference, attribute, value), + isTrue, + ); + final changed = c.markdown; + expect(changed, contains(value)); + if (reference.kind == BusyInlineKind.writersideVariable) { + expect( + changed, + original.replaceRange( + authoredBegin, + authoredBegin + '%product%'.length, + '%$value%', + ), + ); + } + verify(changed); + // Restored document snapshots must rebind positions to their own source. + c.replaceDocument(parse(original)); + expect(c.markdown, original); + verify(c.markdown); + c.replaceDocument(parse(changed)); + expect(c.markdown, changed); + verify(c.markdown); + }); + } + test( + 'multiline HTML links in quoted native items edit their XML destination', + () { + const original = + '# Topic\n\n> - Parent\n> - Nested href="old.topic">Read and Read.\n'; + final document = const MarkdownParser() + .parse( + filePath: 't.md', + source: original, + mode: MarkdownMode.writersideMarkdown, + validateLocalReferences: false, + ) + .busyDocument; + final c = BusyMarkWysiwygDocumentController(document: document); + addTearDown(c.dispose); + Iterable walk(List blocks) sync* { + for (final b in blocks) { + yield b; + yield* walk(b.children); + } + } + + final block = walk( + c.document.blocks, + ).singleWhere((b) => b.plainText == 'Nested Read and Read.'); + final range = busyInlineReferenceRanges(block.inlines).first; + expect( + c.updateInlineReference(block.id, range, 'href', 'new.topic#target'), + isTrue, + ); + expect( + c.markdown, + original.replaceFirst('href="old.topic"', 'href="new.topic#target"'), + ); + }, + ); + for (final prefix in [ + '> - ', + '> - Parent\n> - ', + '- Parent\n - ', + '- Parent\n\n ', + ]) { + for (final lineEnding in ['\n', '\r\n']) { + test( + 'multiline mapped XML attributes retain prefixes: $prefix / ${lineEnding.length}', + () { + final continuation = prefix.startsWith('>') ? '> ' : ' '; + final original = + '# Topic\n\n${prefix}Keys Copy and Copy.\n' + .replaceAll('\n', lineEnding); + BusyDocument parse(String source) => const MarkdownParser() + .parse( + filePath: 't.md', + source: source, + mode: MarkdownMode.writersideMarkdown, + validateLocalReferences: false, + ) + .busyDocument; + final c = BusyMarkWysiwygDocumentController( + document: parse(original), + ); + addTearDown(c.dispose); + Iterable walk(List blocks) sync* { + for (final b in blocks) { + yield b; + yield* walk(b.children); + } + } + + BusyBlock owner() => walk( + c.document.blocks, + ).singleWhere((b) => b.plainText == 'Keys Copy and Copy.'); + BusyInlineStyleRange reference() => + busyInlineReferenceRanges(owner().inlines).first; + var expected = original; + void check() { + expect(c.markdown, expected); + final target = WritersideInspectorTarget.fromSource( + bufferId: 'b', + revision: 1, + source: expected, + document: c.document, + selection: TextSelection.collapsed( + offset: expected.indexOf('Copy', + ' from-keymap-of="other">Copy', + ); + check(); + expect( + c.updateInlineReference( + owner().id, + reference(), + 'not-supported', + 'x', + ), + isFalse, + ); + expect( + c.updateInlineReference( + owner().id, + reference(), + 'key', + 'Unmapped\nline', + ), + isFalse, + ); + check(); + c.replaceDocument(parse(original)); + expect(c.markdown, original); + c.replaceDocument(parse(expected)); + check(); + }, + ); + } + } + test( + 'insertion before a pinned heading retains its exact authored source interval', + () { + BusyDocument parse(String text) => const MarkdownParser() + .parse( + filePath: 't.md', + source: text, + mode: MarkdownMode.writersideMarkdown, + ) + .busyDocument; + const source = '# Topic\n\nBody.\n'; + final document = parse(source); + final target = WritersideInspectorTarget.fromSelection( + bufferId: 'b', + revision: 1, + source: source, + document: document, + blockId: document.blocks.first.id, + ); + const prefix = '\n\n'; + final refreshed = target.refreshUnchangedElement( + revision: 2, + source: prefix + source, + document: parse(prefix + source), + ); + expect(refreshed?.block?.plainText, 'Topic'); + expect(refreshed?.sourceLocation, prefix.length); + }, + ); + test( + 'splitting an attributed list cannot refresh to the shorter neighboring owner', + () { + BusyDocument parse(String text) => const MarkdownParser() + .parse( + filePath: 't.md', + source: text, + mode: MarkdownMode.writersideMarkdown, + ) + .busyDocument; + const source = '- First\n- Second\n\n{id="owner"}\n'; + final document = parse(source); + final target = WritersideInspectorTarget.fromSelection( + bufferId: 'b', + revision: 1, + source: source, + document: document, + blockId: document.blocks.first.id, + ); + final changed = source.replaceFirst( + '- Second', + '\nA separator.\n\n- Second', + ); + expect( + target.refreshUnchangedElement( + revision: 2, + source: changed, + document: parse(changed), + ), + isNull, + ); + }, + ); + test( + 'paragraph attribute reset and Undo retain the pinned authored owner', + () { + BusyDocument parse(String text) => const MarkdownParser() + .parse( + filePath: 't.md', + source: text, + mode: MarkdownMode.writersideMarkdown, + ) + .busyDocument; + const source = '# Topic\n\nOther paragraph.\n\nAfter.\n'; + final c = BusyMarkWysiwygDocumentController(document: parse(source)); + addTearDown(c.dispose); + expect( + c.updateWritersideProperty(c.document.blocks[1].id, 'id', 'anchor'), + isTrue, + ); + final authored = c.markdown; + final document = parse(authored); + final target = WritersideInspectorTarget.fromSelection( + bufferId: 'b', + revision: 1, + source: authored, + document: document, + blockId: document.blocks[1].id, + ); + final refreshed = target.refreshUnchangedElement( + revision: 2, + source: source, + document: parse(source), + ); + expect(refreshed?.block?.plainText, 'Other paragraph.'); + expect(refreshed?.matches('b', 2, source), isTrue); + final redone = refreshed!.refreshUnchangedElement( + revision: 3, + source: authored, + document: document, + ); + expect(redone?.block?.plainText, 'Other paragraph.'); + expect(redone?.block?.attributes['id'], 'anchor'); + expect(redone?.matches('b', 3, authored), isTrue); + }, + ); + test( + 'inserting a sibling after an XML paragraph retains its closed owner', + () { + const source = + '

    Before

    After

    '; + BusyDocument parse(String text) => const WritersideEditingAdapter() + .parseXml(filePath: 't.topic', source: text)!; + final document = parse(source); + final owner = document.blocks.first.children.first; + final target = WritersideInspectorTarget.fromSelection( + bufferId: 'b', + revision: 1, + source: source, + document: document, + blockId: owner.id, + ); + final changed = source.replaceFirst('

    ', '

    \n

    Inserted

    '); + final refreshed = target.refreshUnchangedElement( + revision: 2, + source: changed, + document: parse(changed), + ); + expect(refreshed?.block?.plainText, 'Before'); + expect(refreshed?.sourceLocation, source.indexOf('

    Before')); + expect(refreshed?.matches('b', 2, changed), isTrue); + }, + ); + + test('a pinned native heading refreshes an edit at its final character', () { + BusyDocument parse(String text) => const MarkdownParser() + .parse( + filePath: 't.md', + source: text, + mode: MarkdownMode.writersideMarkdown, + ) + .busyDocument; + const source = '# Topic\n\n## Chapter A\n\nParagraph.\n'; + final document = parse(source); + final target = WritersideInspectorTarget.fromSelection( + bufferId: 'b', + revision: 1, + source: source, + document: document, + blockId: document.blocks[1].id, + ); + final changed = source.replaceFirst('Chapter A', 'Chapter A edited'); + expect( + target + .refreshUnchangedElement( + revision: 2, + source: changed, + document: parse(changed), + ) + ?.block + ?.plainText, + 'Chapter A edited', + ); + }); + test( + 'duplicate paragraph removal cannot rebind a pinned occurrence to its neighbor', + () { + BusyDocument parse(String text) => const MarkdownParser() + .parse( + filePath: 't.md', + source: text, + mode: MarkdownMode.writersideMarkdown, + ) + .busyDocument; + const source = '# Topic\n\nSame paragraph.\n\nSame paragraph.\n'; + final document = parse(source); + final target = WritersideInspectorTarget.fromSelection( + bufferId: 'b', + revision: 1, + source: source, + document: document, + blockId: document.blocks[1].id, + ); + final changed = source.replaceFirst('Same paragraph.\n\n', ''); + expect( + target.refreshUnchangedElement( + revision: 2, + source: changed, + document: parse(changed), + ), + isNull, + ); + }, + ); + for (final xml in [true, false]) { + test( + '${xml ? "XML" : "Markdown"} off-caret refresh preserves property ownership and rejects replacement', + () { + String source(String anchor) => xml + ? '

    Caret A

    Body

    ' + : '# Topic\n\nCaret A\n\n## Chapter {id="$anchor"}\n\nBody\n'; + BusyDocument parse(String text) => xml + ? const WritersideEditingAdapter().parseXml( + filePath: 't.topic', + source: text, + )! + : const MarkdownParser() + .parse( + filePath: 't.md', + source: text, + mode: MarkdownMode.writersideMarkdown, + ) + .busyDocument; + final original = source('original'); + final doc = parse(original); + final b = xml + ? doc.blocks.first.children.last + : doc.blocks.firstWhere((b) => b.plainText == 'Chapter'); + final target = WritersideInspectorTarget.fromSelection( + bufferId: 'b', + revision: 1, + source: original, + document: doc, + blockId: b.id, + ); + final changed = source('changed'); + final refreshed = target.refreshUnchangedElement( + revision: 2, + source: changed, + document: parse(changed), + )!; + expect(refreshed.block?.attributes['id'], 'changed'); + expect(refreshed.matches('b', 2, changed), isTrue); + expect(target.matches('b', 2, changed), isFalse); + expect(target.sameElement(refreshed), isFalse); + final headingEdit = changed.replaceFirst( + xml ? 'title="Chapter"' : '## Chapter', + xml ? 'title="Chapter edited"' : '## Chapter edited', + ); + final updated = refreshed.refreshUnchangedElement( + revision: 3, + source: headingEdit, + document: parse(headingEdit), + ); + expect(updated?.block?.plainText, 'Chapter edited'); + final restored = refreshed.refreshUnchangedElement( + revision: 3, + source: original, + document: parse(original), + )!; + expect(restored.block?.attributes['id'], 'original'); + final removed = xml + ? '

    Caret A

    ' + : '# Topic\n\nCaret A\n'; + final replaced = xml + ? changed.replaceFirst( + '

    Body

    ', + '

    Different body

    ', + ) + : changed.replaceFirst( + '## Chapter {id="changed"}', + 'Other paragraph', + ); + for (final text in [removed, replaced]) { + expect( + target.refreshUnchangedElement( + revision: 2, + source: text, + document: parse(text), + ), + isNull, + ); + } + expect( + target.refreshUnchangedElement( + revision: 2, + source: changed, + document: doc, + ), + isNull, + ); + if (xml) { + final title = original.replaceFirst( + 'title="Chapter"', + 'title="Renamed"', + ); + expect( + target + .refreshUnchangedElement( + revision: 2, + source: title, + document: parse(title), + )! + .block + ?.plainText, + 'Renamed', + ); + } + }, + ); + } + for (final xml in [true, false]) { + test( + '${xml ? "XML" : "Markdown"} Show in Source locates the inspected inline markup', + () { + final source = xml + ? '

    Before Read guide and Guide.

    ' + : 'Before [Read **guide**][ref] and [Guide][ref].\n\n[ref]: target.md\n'; + final doc = xml + ? const WritersideEditingAdapter().parseXml( + filePath: 'a.topic', + source: source, + )! + : const MarkdownParser() + .parse( + filePath: 'a.md', + source: source, + mode: MarkdownMode.writersideMarkdown, + ) + .busyDocument; + final offsets = xml + ? [ + source.indexOf('Copy' + : '%product%'; + final original = xml + ? '

    Before $token and $token.

    ' + : '# Topic\n\nBefore $token and $token.\n'; + BusyDocument parse(String source) => xml + ? const WritersideEditingAdapter().parseXml( + filePath: 't.topic', + source: source, + )! + : const MarkdownParser() + .parse( + filePath: 't.md', + source: source, + mode: MarkdownMode.writersideMarkdown, + ) + .busyDocument; + final model = BusyMarkWysiwygDocumentController( + document: parse(original), + ); + addTearDown(model.dispose); + final kind = shortcut + ? BusyInlineKind.writersideShortcut + : BusyInlineKind.writersideVariable; + void verify(String source) { + final doc = parse(source); + final editor = BusyMarkWysiwygDocumentController(document: doc); + try { + Iterable walk(List blocks) sync* { + for (final b in blocks) { + yield b; + yield* walk(b.children); + } + } + + final block = walk(editor.document.blocks).firstWhere( + (b) => busyInlineReferenceRanges( + b.inlines, + ).any((r) => r.kind == kind), + ); + final refs = busyInlineReferenceRanges( + block.inlines, + ).where((r) => r.kind == kind).toList(); + expect(refs, hasLength(2)); + var cursor = 0; + for (final range in refs) { + final fragment = shortcut + ? ' b.plainText.startsWith('Before'), + ); + final first = busyInlineReferenceRanges(block.inlines).first; + expect( + model.updateInlineReference( + block.id, + first, + shortcut ? 'key' : 'reference', + shortcut ? '\$VeryLongActionIdentifier' : 'long_product_name', + ), + isTrue, + ); + final changed = model.markdown; + expect(changed, contains(token)); + expect(changed, isNot(original)); + verify(changed); + }, + ); + } + } + test( + 'Markdown variable mappings exclude code and escaped literals and preserve formatting', + () { + const source = + '# Topic\n\nCode `%product%` escaped %\\product% and %product% then **%product%**.\n'; + final doc = const MarkdownParser() + .parse( + filePath: 't.md', + source: source, + mode: MarkdownMode.writersideMarkdown, + ) + .busyDocument; + final model = BusyMarkWysiwygDocumentController(document: doc); + addTearDown(model.dispose); + final block = model.document.blocks.firstWhere( + (b) => b.plainText.startsWith('Code'), + ); + final refs = busyInlineReferenceRanges(block.inlines); + expect(refs, hasLength(2)); + final firstOffset = source.indexOf('%product%', source.indexOf(' and ')); + final secondOffset = source.indexOf('%product%', firstOffset + 1); + for (final pair in [(refs[0], firstOffset), (refs[1], secondOffset)]) { + final target = WritersideInspectorTarget.fromSource( + bufferId: 'b', + revision: 1, + source: source, + document: doc, + selection: TextSelection.collapsed(offset: pair.$2 + 2), + ); + expect(target.inline?.start, pair.$1.start); + expect(target.sourceLocation, pair.$2); + } + final literal = WritersideInspectorTarget.fromSource( + bufferId: 'b', + revision: 1, + source: source, + document: doc, + selection: TextSelection.collapsed( + offset: source.indexOf('%product%') + 2, + ), + ); + expect(literal.inline, isNull); + expect(model.markdown, source); + }, + ); + test( + 'Markdown XML-island bindings retain repeated occurrences and mutation identities', + () { + const source = + '# Topic\n\n

    Vars %product% and %product%, Save and Save. Read guide

    \n'; + final doc = const MarkdownParser() + .parse( + filePath: 't.md', + source: source, + mode: MarkdownMode.writersideMarkdown, + ) + .busyDocument; + final model = BusyMarkWysiwygDocumentController(document: doc); + addTearDown(model.dispose); + for (final token in ['%product%', '[]; + for ( + var at = source.indexOf(token); + at >= 0; + at = source.indexOf(token, at + token.length) + ) { + offsets.add(at); + } + for (final offset in offsets) { + final target = WritersideInspectorTarget.fromSource( + bufferId: 'b', + revision: 1, + source: source, + document: doc, + selection: TextSelection.collapsed(offset: offset + 2), + ); + expect( + target.inline, + isNotNull, + reason: 'token=$token offset=$offset', + ); + expect(target.sourceLocation, offset); + if (token == 'guide')); + expect(model.markdown, contains('href="new.topic"')); + }, + ); + for (final xml in [true, false]) { + test( + '${xml ? "XML" : "Markdown"} adjacent empty shortcuts retain distinct owning identities', + () { + final source = xml + ? '

    Keys .

    ' + : '# Topic\n\nKeys .\n'; + BusyDocument parse(String text) => xml + ? const WritersideEditingAdapter().parseXml( + filePath: 't.topic', + source: text, + )! + : const MarkdownParser() + .parse( + filePath: 't.md', + source: text, + mode: MarkdownMode.writersideMarkdown, + ) + .busyDocument; + final model = BusyMarkWysiwygDocumentController( + document: parse(source), + ); + addTearDown(model.dispose); + final first = source.indexOf('

    Body

    '; + final doc = const WritersideEditingAdapter().parseXml( + filePath: 't.topic', + source: source, + )!; + final target = WritersideInspectorTarget.fromTopic( + bufferId: 'b', + revision: 1, + source: source, + document: doc, + ); + final c = BusyMarkWysiwygDocumentController(document: doc); + addTearDown(c.dispose); + expect( + c.resetTopicNavigation() && c.updateTopicNavigation('for', ''), + isTrue, + ); + expect(target.hasSameAuthoredState(c.document), isTrue); + expect(c.updateTopicNavigation('for', 'none'), isTrue); + expect(target.hasSameAuthoredState(c.document), isFalse); + final paragraph = c.document.blocks.first.children.firstWhere( + (b) => b.kind == BusyBlockKind.paragraph, + ); + expect(c.updateWritersideProperty(paragraph.id, 'id', 'body'), isTrue); + expect(target.hasSameAuthoredState(c.document), isFalse); + }, + ); + test( + 'semantic target address survives XML whitespace after native item conversion', + () { + const source = '# Topic\n\n- One\n- Two\n'; + final doc = const MarkdownParser() + .parse( + filePath: 't.md', + source: source, + mode: MarkdownMode.writersideMarkdown, + ) + .busyDocument; + final c = BusyMarkWysiwygDocumentController(document: doc); + addTearDown(c.dispose); + final item = c.document.blocks + .singleWhere((b) => b.attributes['element'] == 'list') + .children + .singleWhere((b) => b.plainText == 'Two'); + expect(c.updateWritersideProperty(item.id, 'id', 'two'), isTrue); + final before = WritersideInspectorTarget.fromSelection( + bufferId: 'b', + revision: 1, + source: source, + document: c.document, + blockId: item.id, + ); + final saved = c.markdown; + final reopened = const MarkdownParser() + .parse( + filePath: 't.md', + source: saved, + mode: MarkdownMode.writersideMarkdown, + ) + .busyDocument; + final list = reopened.blocks.firstWhere( + (b) => b.attributes['element'] == 'list', + ); + final second = list.children.firstWhere( + (b) => b.attributes['id'] == 'two', + ); + final after = WritersideInspectorTarget.fromSelection( + bufferId: 'b', + revision: 2, + source: saved, + document: reopened, + blockId: second.id, + ); + expect(before.semanticAddress, after.semanticAddress); + expect(before.address, isNot(after.address)); + }, + ); + test( + 'unchanged buffer generation does not authorize another inspected element', + () { + const source = '

    A

    B

    '; + final doc = const WritersideEditingAdapter().parseXml( + filePath: 't.topic', + source: source, + )!; + WritersideInspectorTarget target(int index) => + WritersideInspectorTarget.fromSelection( + bufferId: 'b', + revision: 1, + source: source, + document: doc, + blockId: doc.blocks.first.children[index].id, + ); + final delayed = target(0); + expect(delayed.matches('b', 1, source), isTrue); + expect(target(1).matches('b', 1, source), isTrue); + expect(delayed.sameElement(target(1)), isFalse); + expect(delayed.sameElement(target(0)), isTrue); + expect(delayed.sameElement(null), isFalse); + }, + ); + test( + 'Source identifies an inline image override through its attribute range', + () { + const source = + 'Before ![Logo][logo]{src="other.png" alt="Different" width="80"} after.\n\n[logo]: logo.png\n'; + final doc = const MarkdownParser() + .parse( + filePath: 'a.md', + source: source, + mode: MarkdownMode.writersideMarkdown, + ) + .busyDocument; + final target = WritersideInspectorTarget.fromSource( + bufferId: 'b', + revision: 1, + source: source, + document: doc, + selection: TextSelection.collapsed(offset: source.indexOf('width') + 2), + ); + expect(target.inline?.kind, BusyInlineKind.image); + expect(target.inline?.destination, 'other.png'); + expect(target.inline?.attributes['alt'], 'Different'); + }, + ); + test( + 'a stale model cannot be rebound by supplying a newer source generation', + () { + const old = '

    A

    '; + final document = const WritersideEditingAdapter().parseXml( + filePath: 't.topic', + source: old, + )!; + final newer = old.replaceFirst('>A

    ', '>B

    '); + final target = WritersideInspectorTarget.fromSelection( + bufferId: 'b', + revision: 2, + source: newer, + document: document, + blockId: document.blocks.first.children.first.id, + ); + expect(target.matches('b', 2, newer), isFalse); + }, + ); + + test( + 'Source reference-style image and link targets use existing occurrence mappings', + () { + const source = + 'Before ![Logo][asset] and [guide][ref].\n\n[asset]: logo.png\n[ref]: guide.md\n'; + final doc = const MarkdownParser() + .parse( + filePath: 't.md', + source: source, + mode: MarkdownMode.writersideMarkdown, + ) + .busyDocument; + final image = WritersideInspectorTarget.fromSource( + bufferId: 'b', + revision: 1, + source: source, + document: doc, + selection: TextSelection.collapsed(offset: source.indexOf('Logo')), + ); + expect(image.inline?.kind, BusyInlineKind.image); + expect(image.inline?.destination, 'logo.png'); + final link = WritersideInspectorTarget.fromSource( + bufferId: 'b', + revision: 1, + source: source, + document: doc, + selection: TextSelection.collapsed( + offset: source.indexOf('[guide]') + 2, + ), + ); + expect(link.inline?.kind, BusyInlineKind.link); + expect(link.inline?.destination, 'guide.md'); + }, + ); + test('Topic without an H1 remains a document-bound logical entry', () { + const source = '---\nswitcher-label: OS\n---\n\nText.\n'; + final doc = const MarkdownParser() + .parse( + filePath: 't.md', + source: source, + mode: MarkdownMode.writersideMarkdown, + ) + .busyDocument; + final target = WritersideInspectorTarget.fromTopic( + bufferId: 'b', + revision: 1, + source: source, + document: doc, + ); + expect(target.topic, isTrue); + expect(target.block, isNull); + expect(target.matches('b', 1, source), isTrue); + expect(target.document.frontMatter['switcher-label'], 'OS'); + }); + + test( + 'smart variable defaults are contextual and explicit false resets them', + () { + const smart = WritersideEffectiveProperties( + settings: WritersideSettingsConfig(smartIgnoreVars: true), + ); + expect(smart.resolve('code-block', 'ignore-vars', {}).value, 'true'); + expect( + smart.resolve('img', 'ignore-vars', {'src': 'local.png'}).value, + 'false', + ); + expect( + smart.resolve('img', 'ignore-vars', { + 'src': 'https://example.test/%name%.png', + }).value, + 'true', + ); + expect( + smart.resolve('a', 'ignore-vars', {'href': 'other.topic'}).value, + 'false', + ); + expect( + smart.resolve('a', 'ignore-vars', { + 'href': 'https://example.test/%path%', + 'ignore-vars': 'false', + }).value, + 'false', + ); + const ancestor = WritersideEffectiveProperties( + inheritedIgnoreVars: 'true', + ); + expect( + ancestor.resolve('p', 'ignore-vars', {}).origin, + WritersidePropertyOrigin.ancestor, + ); + expect( + ancestor.resolve('p', 'ignore-vars', {'ignore-vars': 'false'}).value, + 'false', + ); + }, + ); + + test( + 'draft retains captured mutation when field is rebuilt or target changes', + () { + final session = WritersidePropertySession(); + var a = ''; + var b = ''; + final draft = session.bind('A-id', 'old', (v) { + a = v; + return true; + }); + session.edit(draft, 'new'); + session.bind('A-id', 'replacement', (v) { + b = v; + return true; + }); + expect(session.transition(), isTrue); + expect(a, 'new'); + expect(b, isEmpty); + }, + ); + for (final reason in WritersidePropertyRejection.values) { + test( + '$reason blocks transition and preserves draft until explicit reversion', + () { + final session = WritersidePropertySession(); + final draft = session.bind( + 'generation-1', + 'old', + (v) => session.reject(reason), + ); + session.edit(draft, 'pending'); + expect(session.transition(), isFalse); + expect(draft.text, 'pending'); + expect(draft.error, reason); + session.revert(draft); + expect(draft.text, 'old'); + expect(session.transition(), isTrue); + }, + ); + } + test('valid no-op does not call mutation or report invalid input', () { + final session = WritersidePropertySession(); + final draft = session.bind( + 'A', + 'same', + (_) => throw StateError('no mutation'), + ); + expect(session.commit(draft), isTrue); + expect(draft.error, isNull); + }); + test('inspected B has B ancestry independently of caret in A', () { + const source = + '

    A

    B
    '; + final doc = const WritersideEditingAdapter().parseXml( + filePath: 't.topic', + source: source, + )!; + final b = doc.blocks.single.children.last; + final target = WritersideInspectorTarget.fromSelection( + bufferId: 'buffer', + revision: 1, + source: source, + document: doc, + blockId: b.children.single.children.single.id, + ); + expect(target.path.map((b) => b.attributes['element']), [ + 'topic', + 'chapter', + 'procedure', + 'step', + ]); + expect(target.path[1].plainText, 'B'); + expect(target.address, [0, 1, 0, 0]); + expect(target.matches('buffer', 2, source), isFalse); + expect(target.matches('other', 1, source), isFalse); + expect(target.matches('buffer', 1, source.replaceFirst('B', 'C')), isFalse); + }); + test( + 'Markdown chapter ancestry is structural while address locates flat authored blocks', + () { + const source = '# Topic\n\n## Parent\n\n### Child\n\nText\n\n## Next\n'; + final doc = const MarkdownParser() + .parse( + filePath: 't.md', + source: source, + mode: MarkdownMode.writersideMarkdown, + ) + .busyDocument; + final text = doc.blocks.firstWhere((b) => b.plainText == 'Text'); + final target = WritersideInspectorTarget.fromSelection( + bufferId: 'b', + revision: 0, + source: source, + document: doc, + blockId: text.id, + ); + expect(target.path.map((b) => b.plainText), [ + 'Topic', + 'Parent', + 'Child', + 'Text', + ]); + expect(target.address, [doc.blocks.indexOf(text)]); + }, + ); + test( + 'Source boundaries are half-open and multi-element selections are explicit', + () { + const source = '

    A

    B

    '; + final doc = const WritersideEditingAdapter().parseXml( + filePath: 't.topic', + source: source, + )!; + WritersideInspectorTarget locate(TextSelection s) => + WritersideInspectorTarget.fromSource( + bufferId: 'b', + revision: 0, + source: source, + document: doc, + selection: s, + ); + final start = source.indexOf('

    B'); + expect( + locate(TextSelection.collapsed(offset: start)).block!.plainText, + 'B', + ); + expect( + locate( + TextSelection( + baseOffset: source.indexOf('

    A'), + extentOffset: start + 5, + ), + ).multiple, + isTrue, + ); + }, + ); + for (final tag in ['chapter', 'procedure', 'code-block', 'deflist']) { + test('$tag state-only and explicit false follow pinned builder', () { + const resolver = WritersideEffectiveProperties(); + expect( + resolver.resolve(tag, 'collapsible', { + 'default-state': 'expanded', + }).value, + 'false', + ); + expect( + resolver.resolve(tag, 'collapsible', { + 'collapsible': 'false', + 'default-state': 'collapsed', + }).value, + 'false', + ); + expect( + resolver.resolve(tag, 'default-state', {'collapsible': 'true'}).value, + 'collapsed', + ); + }); + } + test( + 'absent item state and literal inherited have distinct module-default behavior', + () { + const resolver = WritersideEffectiveProperties( + settings: WritersideSettingsConfig( + defaultProperties: [ + WritersideDefaultProperty( + elementName: 'def', + propertyName: 'default-state', + value: 'collapsed', + ), + ], + ), + ); + const list = {'collapsible': 'true', 'default-state': 'expanded'}; + final absent = resolver.resolve( + 'def', + 'default-state', + {}, + definitionList: list, + ); + final inherited = resolver.resolve('def', 'default-state', { + 'default-state': 'inherited', + }, definitionList: list); + expect(absent.value, 'collapsed'); + expect(absent.origin, WritersidePropertyOrigin.module); + expect(inherited.value, 'expanded'); + expect(inherited.authored, 'inherited'); + expect(inherited.origin, WritersidePropertyOrigin.ancestor); + expect( + resolver.resolve('def', 'collapsible', { + 'collapsible': 'false', + }, definitionList: list).value, + 'true', + ); + }, + ); + test( + 'module defaults and reset preserve authored absence and unresolved expressions', + () { + const resolver = WritersideEffectiveProperties( + settings: WritersideSettingsConfig( + defaultProperties: [ + WritersideDefaultProperty( + elementName: 'code-block', + propertyName: 'collapsible', + value: 'true', + ), + ], + ), + ); + final attrs = {}; + expect( + resolver.resolve('code-block', 'collapsible', attrs).value, + 'true', + ); + expect(attrs, isEmpty); + final variable = resolver.resolve('code-block', 'collapsible', { + 'collapsible': '%unknown%', + }); + expect(variable.authored, '%unknown%'); + expect(variable.unresolved, isTrue); + expect(variable.origin, WritersidePropertyOrigin.unresolved); + expect( + const WritersideEffectiveProperties(variables: {'collapse': 'false'}) + .resolve('chapter', 'collapsible', {'collapsible': '%collapse%'}) + .value, + 'false', + ); + }, + ); + test( + 'emphasis validates against displayed extraction and extraction tuples conflict', + () { + expect( + validateWritersideProperty( + 'code-block', + 'emphasize-lines', + '2', + attributes: {'include-lines': '2-3'}, + codeSource: 'one\ntwo\nthree', + ), + isNull, + ); + expect( + validateWritersideProperty( + 'code-block', + 'emphasize-lines', + '3', + attributes: {'include-lines': '2-3'}, + codeSource: 'one\ntwo\nthree', + ), + contains('invalid-lines'), + ); + expect( + validateWritersideProperty( + 'code-block', + 'include-lines', + '1', + attributes: {'include-symbol': 'main'}, + ), + contains('conflicts'), + ); + expect( + validateWritersideProperty('chapter', 'default-state', null), + isNull, + ); + expect( + validateWritersideProperty('def', 'default-state', 'inherited'), + isNull, + ); + expect( + validateWritersideProperty('chapter', 'default-state', 'inherited'), + contains('allowed'), + ); + }, + ); +} diff --git a/test/src/writerside_native_list_inspector_test.dart b/test/src/writerside_native_list_inspector_test.dart new file mode 100644 index 00000000..4461cb99 --- /dev/null +++ b/test/src/writerside_native_list_inspector_test.dart @@ -0,0 +1,549 @@ +import 'package:busymark/src/editor/wysiwyg/writerside_inspector_target.dart'; +import 'package:busymark/src/editor/wysiwyg/wysiwyg_document_controller.dart'; +import 'package:busymark/src/markdown/busymark_document.dart'; +import 'package:busymark/src/markdown/markdown_model.dart'; +import 'package:busymark/src/markdown/markdown_parser.dart'; +import 'package:busymark/src/writerside/writerside_effective_properties.dart'; +import 'package:busymark/src/writerside/writerside_model.dart'; +import 'package:busymark/src/writerside/writerside_schema.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter/services.dart'; +import 'package:busymark/src/editor/wysiwyg/wysiwyg_commands.dart'; + +BusyDocument parse(String text) => const MarkdownParser() + .parse( + filePath: 'lists.md', + source: text, + mode: MarkdownMode.writersideMarkdown, + validateLocalReferences: false, + ) + .busyDocument; +Iterable walk(Iterable blocks) sync* { + for (final b in blocks) { + yield b; + yield* walk(b.children); + } +} + +List lists(BusyDocument d) => + walk(d.blocks).where((b) => b.attributes['element'] == 'list').toList(); + +void main() { + test( + 'deleting list members removes their empty owner and rejects the captured target', + () { + const source = 'Before\n\n- First\n- Second\n\n{id="owner"}\n\nAfter\n'; + final c = BusyMarkWysiwygDocumentController(document: parse(source)); + addTearDown(c.dispose); + final owner = lists(c.document).single; + final captured = WritersideInspectorTarget.fromSelection( + bufferId: 'b', + revision: 1, + source: source, + document: c.document, + blockId: owner.id, + ); + final before = c.document.blocks.first; + final after = c.document.blocks.last; + expect( + c.deleteTextSelection( + firstBlockId: before.id, + firstStartOffset: 0, + lastBlockId: after.id, + lastEndOffset: after.plainText.length, + removedBlockIds: walk(c.document.blocks).map((b) => b.id), + ), + isNotNull, + ); + expect(lists(c.document), isEmpty); + expect(c.markdown, isNot(contains('owner'))); + expect( + captured.refreshUnchangedElement( + revision: 2, + source: c.markdown, + document: c.document, + ), + isNull, + ); + expect(c.updateWritersideProperty(owner.id, 'id', 'unrelated'), isFalse); + c.updateBlockText(c.document.blocks.first.id, 'New content'); + expect(c.markdown, contains('New content')); + }, + ); + for (final marker in ['3)', '+', '*', '-']) { + test( + 'ordinary caption editing retains native marker spelling ($marker)', + () { + final secondMarker = marker == '3)' ? '4)' : marker; + final c = BusyMarkWysiwygDocumentController( + document: parse('$marker First\n$secondMarker Second\n'), + ); + addTearDown(c.dispose); + final second = lists(c.document).single.children.last.id; + c.updateBlockText(second, 'Second edited'); + c.rebaseCommittedSource(c.markdown); + expect( + c.markdown, + contains('$marker First\n$secondMarker Second edited'), + ); + expect(lists(c.document), hasLength(1)); + expect(lists(c.document).single.children.last.id, second); + }, + ); + } + test( + 'multi-item indent/outdent preserves order and each level numbering', + () { + final c = BusyMarkWysiwygDocumentController( + document: parse('3. Parent\n4. Second\n5. Third\n'), + ); + addTearDown(c.dispose); + final ids = lists( + c.document, + ).single.children.skip(1).map((b) => b.id).toList(); + expect(c.indentListItems(ids), isTrue); + c.rebaseCommittedSource(c.markdown); + expect( + lists(c.document).last.children.map((b) => b.attributes['marker']), + ['1.', '2.'], + ); + expect(c.outdentListItems(ids), isTrue); + c.rebaseCommittedSource(c.markdown); + expect(lists(c.document).single.children.map((b) => b.plainText), [ + 'Parent', + 'Second', + 'Third', + ]); + expect( + lists(c.document).single.children.map((b) => b.attributes['marker']), + ['3.', '4.', '5.'], + ); + expect(lists(c.document).single.children.skip(1).map((b) => b.id), ids); + }, + ); + test('bulk promotion cannot discard the whole list settings', () { + final c = BusyMarkWysiwygDocumentController( + document: parse('- First\n- Second\n\n{id="owner"}\n'), + ); + addTearDown(c.dispose); + final original = c.markdown; + c.applyBlockCommandToBlocks( + lists(c.document).single.children.map((b) => b.id), + BusyWysiwygBlockCommand.paragraph, + ); + expect(c.markdown, original); + }); + test( + 'list attribute edits rebind later embedded XML descendants to current source', + () { + const source = + '- Item\n\n

    %product%
    %product%

    \n'; + final c = BusyMarkWysiwygDocumentController(document: parse(source)); + addTearDown(c.dispose); + expect( + c.updateWritersideProperty( + lists(c.document).single.id, + 'id', + 'whole-list', + ), + isTrue, + ); + c.rebaseCommittedSource(c.markdown); + final paragraph = walk( + c.document.blocks, + ).singleWhere((b) => b.attributes['id'] == 'body'); + expect( + paragraph.sourceSpan!.startOffset, + c.markdown.indexOf('

    b.plainText), [ + 'First', + 'Second', + 'Third', + ]); + }); + + test('quoted native lists have occurrence-specific source boundaries', () { + const source = '> - First\n> - Nested\n> - Second\n\nAfter.\n'; + final d = parse(source); + final all = lists(d); + expect(all, hasLength(2)); + expect(all.first.sourceSpan!.startOffset, source.indexOf('- First')); + expect(all.last.sourceSpan!.startOffset, source.indexOf('- Nested')); + final c = BusyMarkWysiwygDocumentController(document: d); + addTearDown(c.dispose); + expect(c.updateWritersideProperty(all.last.id, 'id', 'nested'), isTrue); + final edited = c.markdown; + expect(lists(parse(edited)).last.attributes['id'], 'nested'); + expect(lists(parse(edited)).first.attributes['id'], isNull); + expect(edited, endsWith('\n\nAfter.\n')); + }); + test( + 'newly typed and split lists acquire parser ownership without losing editing IDs', + () { + final c = BusyMarkWysiwygDocumentController(document: parse('Text\n')); + addTearDown(c.dispose); + final first = c.document.blocks.first.id; + c.applyBlockCommand(first, BusyWysiwygBlockCommand.unorderedList); + c.rebaseCommittedSource(c.markdown); + expect(lists(c.document), hasLength(1)); + expect(lists(c.document).single.children.single.id, first); + final second = c.applyEnterAt(first, 4)!.blockId; + c.updateBlockText(second, 'Second'); + c.rebaseCommittedSource(c.markdown); + expect(lists(c.document).single.children.map((b) => b.id), [ + first, + second, + ]); + expect(c.elementPath(second).first.attributes['element'], 'list'); + expect( + c.updateWritersideProperty(lists(c.document).single.id, 'columns', '2'), + isTrue, + ); + c.rebaseCommittedSource(c.markdown); + c.updateBlockText(second, 'Second edited'); + c.rebaseCommittedSource(c.markdown); + expect(c.markdown, contains('Second edited')); + expect(parse(c.markdown).blocks.first.attributes['columns'], '2'); + }, + ); + + test( + 'list splitting preserves list attributes and promotes ordinary content', + () { + final c = BusyMarkWysiwygDocumentController( + document: parse('- First\n- Middle\n- Last\n\n{id="original-list"}\n'), + ); + addTearDown(c.dispose); + final middle = lists(c.document).single.children[1].id; + c.applyBlockCommand(middle, BusyWysiwygBlockCommand.paragraph); + c.rebaseCommittedSource(c.markdown); + expect(lists(c.document), hasLength(2)); + expect(lists(c.document).first.attributes['id'], 'original-list'); + expect(lists(c.document).last.attributes['id'], isNull); + expect(c.blockById(middle)!.kind, BusyBlockKind.paragraph); + expect(c.blockById(middle)!.plainText, 'Middle'); + expect( + c.elementPath(middle).any((b) => b.attributes['element'] == 'list'), + isFalse, + ); + expect(c.markdown, contains('Middle')); + }, + ); + test( + 'parser owns loose, nested, mixed and adjacent distinct lists before attributes', + () { + const source = + '# Topic\n\n## Chapter\n\n3. Parent **caption**\n\n Second paragraph.\n\n 4. Nested first\n 5. Nested second\n\n4. Sibling\n\n+ [x] Done\n+ Pending\n\n- Separate first\n- Separate second\n'; + final d = parse(source); + final c = BusyMarkWysiwygDocumentController(document: d); + addTearDown(c.dispose); + expect(lists(d), hasLength(4)); + final outer = lists(d).first; + expect(outer.children.map((b) => b.plainText), [ + 'Parent caption', + 'Sibling', + ]); + final parent = outer.children.first; + expect(parent.children.first.plainText, 'Second paragraph.'); + final nested = parent.children.last; + expect(nested.attributes['element'], 'list'); + expect(nested.sourceSpan!.startOffset, source.indexOf('4. Nested first')); + final item = nested.children.last; + expect(item.sourceSpan!.startOffset, source.indexOf('5. Nested second')); + expect(c.elementPath(item.id).map((b) => b.id), [ + d.blocks[0].id, + d.blocks[1].id, + outer.id, + parent.id, + nested.id, + item.id, + ]); + expect(lists(d)[2].children.map((b) => b.kind), [ + BusyBlockKind.taskListItem, + BusyBlockKind.unorderedListItem, + ]); + expect(c.markdown, source); + expect(walk(c.document.blocks).any((b) => b.dirty), isFalse); + final fromSource = WritersideInspectorTarget.fromSource( + bufferId: 'b', + revision: 1, + source: source, + document: d, + selection: TextSelection.collapsed( + offset: source.indexOf('5. Nested second') + 3, + ), + ); + expect(fromSource.block?.id, item.id); + }, + ); + + for (final newline in ['\n', '\r\n']) { + test('whole-list attributes preserve native source and reset ($newline)', () { + final source = + '# Topic\n\n3) Parent **caption** [guide](guide.md)\n\n Second paragraph.\n\n 4) Nested first\n 5) Nested second\n\n1) Sibling\n\nAfter.\n' + .replaceAll('\n', newline); + final c = BusyMarkWysiwygDocumentController(document: parse(source)); + addTearDown(c.dispose); + final list = lists(c.document).first; + for (final field in { + 'id': 'whole-list', + 'start': '10', + 'columns': '2', + 'sorted': 'asc', + }.entries) { + expect( + c.updateWritersideProperty(list.id, field.key, field.value), + isTrue, + ); + } + final saved = c.markdown; + expect(saved, isNot(contains(' b.attributes['id'] == null), + isTrue, + ); + for (final field in ['id', 'start', 'columns', 'sorted']) { + expect(c.updateWritersideProperty(list.id, field, null), isTrue); + } + expect(c.markdown, source); + }); + } + + test( + 'nested-list property edits preserve its parent and surrounding indentation', + () { + const source = + '3. Parent\n\n Extra paragraph.\n\n 4. Nested first\n 5. Nested second\n\n4. Sibling\n\nAfter.\n'; + final c = BusyMarkWysiwygDocumentController(document: parse(source)); + addTearDown(c.dispose); + final nested = lists(c.document).last; + expect(c.updateWritersideProperty(nested.id, 'id', 'nested'), isTrue); + expect(c.updateWritersideProperty(nested.id, 'start', '12'), isTrue); + final saved = c.markdown; + expect( + saved, + startsWith( + '3. Parent\n\n Extra paragraph.\n\n 4. Nested first\n 5. Nested second', + ), + ); + expect(saved, contains('\n\n {id="nested" start="12"}\n\n4. Sibling')); + expect(saved, endsWith('\n\nAfter.\n')); + final reopened = parse(saved); + expect(lists(reopened).first.attributes['start'], isNull); + expect(lists(reopened).last.attributes['start'], '12'); + expect(lists(reopened).last.attributes['id'], 'nested'); + expect(c.updateWritersideProperty(nested.id, 'id', null), isTrue); + expect(c.updateWritersideProperty(nested.id, 'start', null), isTrue); + expect(c.markdown, source); + }, + ); + + test( + 'mixed markers block lossy item conversion while whole-list edits remain native', + () { + const source = + '- [x] Done **item**\n- Plain item\n- [ ] Pending\n\n{id="whole-list" columns="2"}\n'; + final c = BusyMarkWysiwygDocumentController(document: parse(source)); + addTearDown(c.dispose); + final owner = lists(c.document).single; + for (final item in owner.children) { + expect( + c.updateWritersideProperty(item.id, 'id', 'item-anchor'), + isFalse, + ); + expect(c.markdown, source); + } + expect(c.updateWritersideProperty(owner.id, 'columns', '3'), isTrue); + final saved = parse(c.markdown); + expect(lists(saved).single.attributes['columns'], '3'); + expect(lists(saved).single.children.map((b) => b.kind), [ + BusyBlockKind.taskListItem, + BusyBlockKind.unorderedListItem, + BusyBlockKind.taskListItem, + ]); + expect(lists(saved).single.children.first.attributes['task'], 'true'); + expect(lists(saved).single.children.last.attributes['task'], 'false'); + expect(c.markdown, isNot(contains(' b.attributes['element'] == 'li') + .toList(); + expect(items, hasLength(2)); + expect( + items.map((b) => b.kind), + everyElement(BusyBlockKind.taskListItem), + ); + expect(items.first.attributes['task'], 'true'); + expect(items.last.attributes['task'], 'false'); + expect(items.last.attributes['id'], 'pending'); + }, + ); + + test( + 'independent list eligibility includes narrowly supported checkbox and numeric domains', + () { + expect(WritersideSchema.valuesFor('list', 'type'), { + 'bullet', + 'decimal', + 'alpha-lower', + 'checkbox', + 'none', + }); + expect(WritersideSchema.valuesFor('list', 'sorted'), { + 'none', + 'asc', + 'desc', + }); + for (final value in ['0', '-1', '6', '1.5', 'text']) { + expect( + WritersideSchema.invalidAttributeValue('list', 'columns', value), + isNotNull, + ); + } + for (final value in ['1', '2', '5']) { + expect( + WritersideSchema.invalidAttributeValue('list', 'columns', value), + isNull, + ); + } + expect( + WritersideSchema.invalidAttributeValue('list', 'start', '0'), + isNotNull, + ); + expect( + WritersideSchema.invalidAttributeValue('list', 'start', '12'), + isNull, + ); + expect( + WritersideSchema.invalidAttributeValue('list', 'type', 'invented'), + isNotNull, + ); + }, + ); + + test( + 'marker-derived style/start and module defaults have builder-verified precedence', + () { + const r = WritersideEffectiveProperties( + settings: WritersideSettingsConfig( + defaultProperties: [ + WritersideDefaultProperty( + elementName: 'list', + propertyName: 'type', + value: 'alpha-lower', + ), + WritersideDefaultProperty( + elementName: 'list', + propertyName: 'start', + value: '5', + ), + ], + ), + ); + final seven = { + 'busymark-native-list-type': 'decimal', + 'busymark-native-list-start': '7', + }; + expect(r.resolve('list', 'type', seven).value, 'decimal'); + expect( + r.resolve('list', 'type', seven).origin, + WritersidePropertyOrigin.markdownMarker, + ); + expect(r.resolve('list', 'start', seven).value, '7'); + expect(r.resolve('list', 'start', {...seven, 'start': '9'}).value, '9'); + expect( + r.resolve('list', 'start', { + ...seven, + 'busymark-native-list-start': '1', + }).value, + '5', + ); + expect( + r.resolve('list', 'type', { + 'busymark-native-list-type': 'bullet', + }).value, + 'alpha-lower', + ); + }, + ); +} diff --git a/test/src/writerside_project_creator_test.dart b/test/src/writerside_project_creator_test.dart index f3c2e3de..4251ab6c 100644 --- a/test/src/writerside_project_creator_test.dart +++ b/test/src/writerside_project_creator_test.dart @@ -75,8 +75,8 @@ void main() { final result = await creator.create( WritersideProjectCreateRequest( parentDirectoryPath: parent.path, - projectName: 'Linguality Docs', - directoryName: 'linguality-docs', + projectName: 'Guidelines Docs', + directoryName: 'example-docs', instanceName: 'User Guide', topicTitle: 'Introduction', ), @@ -92,7 +92,7 @@ void main() { ); final module = await moduleService.load(result.rootPath); - expect(module.config.moduleName, 'Linguality Docs'); + expect(module.config.moduleName, 'Guidelines Docs'); expect(module.instances.single.startPage, 'introduction.md'); expect(module.topicsByFileName.keys, contains('introduction.md')); }); diff --git a/test/src/writerside_project_test.dart b/test/src/writerside_project_test.dart index 309621ed..f862090b 100644 --- a/test/src/writerside_project_test.dart +++ b/test/src/writerside_project_test.dart @@ -26,6 +26,32 @@ DocumentBuffer _documentBuffer(Workspace workspace, String source) { } void main() { + test( + 'formal root diagnostics reject broad conditional capabilities while preserving input', + () async { + final root = await Directory.systemTemp.createTemp( + 'writerside-applicability-', + ); + addTearDown(() => root.delete(recursive: true)); + final module = await _writeDiscoveryModule(root, 'main', 'main'); + final file = File(p.join(module.path, 'topics', 'main.topic')); + const authored = + '

    Text

  • Task
  • '; + await file.writeAsString(authored); + final project = await const WritersideProjectService().load(root.path); + final unknown = project.index.diagnostics + .where((d) => d.code == 'writerside.schema.unknown-attribute') + .toList(); + expect(unknown, hasLength(3)); + expect(unknown.map((d) => d.args['attribute']).toSet(), { + 'filter', + 'instance', + 'switcher-key', + }); + expect(await file.readAsString(), authored); + }, + ); + test( 'discovers modules, indexes symbols, and resolves origin references', () async { diff --git a/test/src/writerside_properties_round_trip_test.dart b/test/src/writerside_properties_round_trip_test.dart new file mode 100644 index 00000000..7061195c --- /dev/null +++ b/test/src/writerside_properties_round_trip_test.dart @@ -0,0 +1,1029 @@ +import 'package:busymark/src/markdown/busymark_markdown_serializer.dart'; +import 'dart:io'; + +import 'package:busymark/src/editor/wysiwyg/wysiwyg_document_controller.dart'; +import 'package:busymark/src/markdown/busymark_document.dart'; +import 'package:busymark/src/markdown/markdown_model.dart'; +import 'package:busymark/src/markdown/markdown_parser.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:busymark/src/editor/wysiwyg/writerside_editing_adapter.dart'; +import 'package:busymark/src/editor/wysiwyg/wysiwyg_inline_controller.dart'; + +BusyDocument parse(String source) => const MarkdownParser() + .parse( + filePath: 'properties.md', + source: source, + mode: MarkdownMode.writersideMarkdown, + ) + .busyDocument; + +void main() { + test( + 'semantic text in a Markdown table preserves the pipe delimiter and offsets', + () { + const inline = BusyInline( + kind: BusyInlineKind.writersideUiPath, + text: 'File | Open', + ); + final result = const BusyMarkMarkdownSerializer() + .serializeInlineFragmentAtTextOffset( + [inline], + tableCell: true, + textOffset: 6, + ); + expect(result.source, 'File | Open'); + expect(result.sourceOffset, result.source.indexOf(' Open')); + }, + ); + + test('semantic XML escaping and mixed formatting retain source offsets', () { + final inline = BusyInline( + kind: BusyInlineKind.writersideControl, + text: '', + children: const [ + BusyInline(kind: BusyInlineKind.text, text: 'A & '), + BusyInline( + kind: BusyInlineKind.strong, + text: '', + children: [BusyInline(kind: BusyInlineKind.text, text: '')], + ), + ], + ); + final result = const BusyMarkMarkdownSerializer() + .serializeInlineFragmentAtTextOffset([inline], textOffset: 5); + expect(result.source, 'A & <File>'); + expect(result.sourceOffset, result.source.indexOf('File')); + }); + + for (final image in [false, true]) { + test( + '${image ? 'Image attribute-like alt text' : 'Linked code example'} survives content edit save reopen', + () async { + final source = image + ? 'See ![{id="literal"}](logo.png){id="reference" width="24"}\n' + : 'See [`{id="literal"}`](guide.md){id="reference"}\n'; + final c = BusyMarkWysiwygDocumentController(document: parse(source)); + addTearDown(c.dispose); + void verify(BusyBlock paragraph, String prefix) { + expect(paragraph.attributes['id'], isNull); + expect(paragraph.plainText, '$prefix {id="literal"}'); + final reference = paragraph.inlines.singleWhere( + (i) => + i.kind == (image ? BusyInlineKind.image : BusyInlineKind.link), + ); + expect(reference.attributes['id'], 'reference'); + expect( + reference.attributes['busymark-writerside-attribute-token'], + isNull, + ); + expect(reference.destination, image ? 'logo.png' : 'guide.md'); + if (image) { + expect(reference.text, '{id="literal"}'); + expect(reference.attributes['width'], '24'); + } else { + expect(reference.children.single.kind, BusyInlineKind.code); + expect(reference.children.single.text, '{id="literal"}'); + } + } + + final paragraph = c.document.blocks.single; + verify(paragraph, 'See'); + c.updateBlockText(paragraph.id, 'Read {id="literal"}'); + final directory = await Directory.systemTemp.createTemp( + 'reference-token-', + ); + addTearDown(() => directory.delete(recursive: true)); + final file = File('${directory.path}/edited.md'); + await file.writeAsString(c.markdown); + verify(parse(await file.readAsString()).blocks.single, 'Read'); + expect( + c.markdown, + contains(image ? '![\\{id="literal"}]' : '[`{id="literal"}`]'), + ); + expect(c.markdown, contains('{id="reference"')); + }, + ); + } + for (final explicitStart in [false, true]) { + test( + 'Ordered item conversion preserves ${explicitStart ? 'authored outer override' : 'native outer marker'} and nested start', + () async { + final source = + '3. Parent\n 4. Nested first\n 5. Nested second\n4. Sibling\n' + '${explicitStart ? '\n{start="10" id="numbered-list"}\n' : ''}'; + final c = BusyMarkWysiwygDocumentController(document: parse(source)); + addTearDown(c.dispose); + final target = c.document.blocks.first.children.first; + final identity = explicitStart ? 'numbered-parent' : 'marker-parent'; + expect(c.updateWritersideProperty(target.id, 'id', identity), isTrue); + final directory = await Directory.systemTemp.createTemp( + 'numbered-list-', + ); + addTearDown(() => directory.delete(recursive: true)); + final file = File('${directory.path}/edited.md'); + await file.writeAsString(c.markdown); + final list = parse(await file.readAsString()).blocks.single; + expect(list.attributes['start'], explicitStart ? '10' : '3'); + final item = list.children.firstWhere( + (b) => b.attributes['element'] == 'li', + ); + expect(item.attributes['id'], identity); + final nested = item.children.singleWhere( + (b) => b.attributes['element'] == 'list', + ); + expect(nested.attributes['type'], 'decimal'); + expect(nested.attributes['start'], '4'); + expect( + list.children.where((b) => b.attributes['element'] == 'li'), + hasLength(2), + ); + expect( + nested.children.where((b) => b.attributes['element'] == 'li'), + hasLength(2), + ); + for (final fixture in [ + 'property_conformance', + 'property_defaults_conformance', + ]) { + expect( + File( + 'test/fixtures/writerside/$fixture/topics/properties-markdown.md', + ).readAsStringSync(), + contains(c.markdown.trim()), + reason: 'The builder must check actual serialized conversion bytes', + ); + } + }, + ); + } + test('nested authored list override survives parent item conversion', () async { + const source = + '3. Parent\n 4. Nested first\n 5. Nested second\n\n' + ' {start="7" id="nested-list"}\n\n4. Sibling\n\n{start="10"}\n'; + final c = BusyMarkWysiwygDocumentController(document: parse(source)); + addTearDown(c.dispose); + final parent = c.document.blocks.single.children.first; + expect( + c.updateWritersideProperty(parent.id, 'id', 'nested-parent'), + isTrue, + ); + final directory = await Directory.systemTemp.createTemp( + 'nested-list-override-', + ); + addTearDown(() => directory.delete(recursive: true)); + final file = File('${directory.path}/edited.md'); + await file.writeAsString(c.markdown); + final list = parse(await file.readAsString()).blocks.single; + expect(list.attributes['start'], '10'); + final item = list.children.firstWhere( + (b) => b.attributes['element'] == 'li', + ); + final nested = item.children.singleWhere( + (b) => b.attributes['element'] == 'list', + ); + expect(nested.attributes['start'], '7'); + expect(nested.attributes['id'], 'nested-list'); + expect(c.markdown, isNot(contains('busymark-'))); + expect( + File( + 'test/fixtures/writerside/property_conformance/topics/properties-markdown.md', + ).readAsStringSync(), + contains(c.markdown.trim()), + ); + }); + test( + 'list-item properties under Markdown chapters use physical list ownership', + () { + const source = + '# Topic\n\n## Chapter\n\n- First **item**\n- Second item\n\nAfter.\n'; + final c = BusyMarkWysiwygDocumentController(document: parse(source)); + addTearDown(c.dispose); + final target = c.document.blocks + .firstWhere((b) => b.attributes['element'] == 'list') + .children + .firstWhere((b) => b.plainText == 'Second item'); + expect(c.updateWritersideProperty(target.id, 'id', 'second'), isTrue); + expect(c.markdown, startsWith('# Topic\n\n## Chapter\n\n')); + expect(c.markdown, endsWith('\n\nAfter.\n')); + final reopened = parse(c.markdown); + final list = reopened.blocks.firstWhere( + (b) => b.attributes['element'] == 'list', + ); + final item = list.children + .where((b) => b.attributes['element'] == 'li') + .last; + expect(item.attributes['id'], 'second'); + final next = BusyMarkWysiwygDocumentController(document: reopened); + addTearDown(next.dispose); + expect( + next.elementPath(item.id).map((b) => b.plainText), + contains('Chapter'), + ); + }, + ); + test( + 'native list attribute line belongs to the list and remains native', + () async { + const source = + '- One\n- Two\n\n{id="whole-list" instance="web"}\n\nAfter.\n'; + final c = BusyMarkWysiwygDocumentController(document: parse(source)); + addTearDown(c.dispose); + final list = c.document.blocks.first; + expect(list.attributes['element'], 'list'); + expect(list.attributes['id'], 'whole-list'); + expect(list.children, hasLength(2)); + expect(list.children.every((b) => b.attributes['id'] == null), isTrue); + expect(c.markdown, source); + expect(c.updateWritersideProperty(list.id, 'id', 'saved-list'), isTrue); + expect(c.updateWritersideProperty(list.id, 'filter', 'release'), isTrue); + expect(c.markdown, isNot(contains(' file.parent.delete(recursive: true)); + final reopened = parse(await file.readAsString()); + expect(reopened.blocks.first.attributes['id'], 'saved-list'); + expect(reopened.blocks.first.attributes['filter'], 'release'); + expect(reopened.blocks.first.children, hasLength(2)); + expect(reopened.blocks.last.plainText, 'After.'); + expect( + c.updateWritersideProperty(list.children.last.id, 'id', 'item-two'), + isTrue, + ); + final xmlList = parse(c.markdown).blocks.first; + expect(xmlList.attributes['id'], 'saved-list'); + expect(xmlList.attributes['instance'], 'web'); + expect( + xmlList.children + .where((b) => b.attributes['element'] == 'li') + .last + .attributes['id'], + 'item-two', + ); + expect(c.markdown, endsWith('After.\n')); + }, + ); + test( + 'paragraph attribute ownership survives content editing and reopening', + () async { + final directory = await Directory.systemTemp.createTemp( + 'paragraph-ownership-', + ); + addTearDown(() => directory.delete(recursive: true)); + for (final source in [ + 'Use `{id="example"}`\n', + 'Use \\{id="example"\\}\n', + 'Use \\{id="example"}\n', + 'Use {id="example"}\n', + 'Use \\{x.y="example"}\n', + 'Use [**link**](guide.md){id="example"}\n', + 'Use ![Logo](logo.png){id="example" width="24"}\n', + ]) { + final c = BusyMarkWysiwygDocumentController(document: parse(source)); + addTearDown(c.dispose); + final paragraph = c.document.blocks.first; + expect(paragraph.attributes['id'], isNull, reason: source); + c.updateBlockText( + paragraph.id, + paragraph.plainText.replaceFirst('Use', 'Try'), + ); + final file = File('${directory.path}/edited.md'); + await file.writeAsString(c.markdown); + final reopened = parse(await file.readAsString()).blocks.first; + expect(reopened.attributes['id'], isNull, reason: c.markdown); + expect( + reopened.plainText, + paragraph.plainText.replaceFirst('Use', 'Try'), + ); + if (source.contains('`')) { + expect(reopened.inlines.last.kind, BusyInlineKind.code); + expect(reopened.inlines.last.text, '{id="example"}'); + } + if (source.contains('](')) { + final reference = busyInlineReferenceRanges(reopened.inlines) + .firstWhere( + (r) => + r.kind == + (source.contains('![') + ? BusyInlineKind.image + : BusyInlineKind.link), + ); + expect(reference.attributes['id'], 'example'); + } + } + }, + ); + test('native list item properties belong to the item after saving', () async { + final directory = await Directory.systemTemp.createTemp('list-properties-'); + addTearDown(() => directory.delete(recursive: true)); + final c = BusyMarkWysiwygDocumentController( + document: parse('- First **item**\n- Second item\n'), + ); + addTearDown(c.dispose); + final target = c.document.blocks.first.children[1]; + expect(c.updateWritersideProperty(target.id, 'id', 'second'), isTrue); + expect(c.updateWritersideProperty(target.id, 'filter', 'release'), isTrue); + final file = File('${directory.path}/list.md'); + await file.writeAsString(c.markdown); + final reopened = parse(await file.readAsString()); + final list = reopened.blocks.first; + expect(list.attributes['element'], 'list'); + expect(list.attributes['id'], isNull); + final items = list.children + .where((b) => b.attributes['element'] == 'li') + .toList(); + expect(items.first.attributes['id'], isNull); + expect(items[1].attributes['id'], 'second', reason: c.markdown); + expect(items[1].attributes['filter'], 'release'); + expect( + items.first.children.where((b) => !b.isSourceOnly).first.plainText, + contains('First item'), + ); + expect(c.updateWritersideProperty(target.id, 'id', null), isTrue); + expect( + parse(c.markdown).blocks.first.children + .where((b) => b.attributes['element'] == 'li') + .last + .attributes['id'], + isNull, + ); + }); + test( + 'task item overrides preserve checks through XML and later edits', + () async { + final directory = await Directory.systemTemp.createTemp( + 'task-properties-', + ); + addTearDown(() => directory.delete(recursive: true)); + final c = BusyMarkWysiwygDocumentController( + document: parse('- [x] Checked **item**\n- [ ] Unchecked item\n'), + ); + addTearDown(c.dispose); + final target = c.document.blocks.first.children.last; + expect(c.updateWritersideProperty(target.id, 'id', 'task-item'), isTrue); + expect(c.updateWritersideProperty(target.id, 'instance', 'web'), isTrue); + final file = File('${directory.path}/tasks.md'); + await file.writeAsString(c.markdown); + final reopened = parse(await file.readAsString()); + final items = reopened.blocks.first.children + .where((b) => b.attributes['element'] == 'li') + .toList(); + expect( + items.map((b) => b.kind), + everyElement(BusyBlockKind.taskListItem), + ); + expect(items.first.attributes['task'], 'true'); + expect(items.last.attributes['task'], 'false'); + expect(items.last.attributes['id'], 'task-item'); + expect(items.last.attributes['instance'], 'web'); + expect(c.markdown, isNot(contains(' task='))); + final next = BusyMarkWysiwygDocumentController(document: reopened); + addTearDown(next.dispose); + expect( + next.updateWritersideProperty(items.last.id, 'id', 'updated-task'), + isTrue, + ); + next.toggleTaskChecked([items.last.id]); + expect( + parse(next.markdown).blocks.first.children + .where((b) => b.attributes['element'] == 'li') + .last + .attributes['task'], + 'true', + ); + expect(next.markdown, contains('id="updated-task"')); + expect( + next.updateWritersideProperty(items.last.id, 'checked', 'false'), + isTrue, + ); + expect(next.blockById(items.last.id)!.attributes['task'], 'false'); + expect( + next.updateWritersideProperty(items.last.id, 'checked', 'invalid'), + isFalse, + ); + await file.writeAsString(next.markdown); + expect( + parse(await file.readAsString()).blocks.first.children + .where((b) => b.attributes['element'] == 'li') + .last + .attributes['checked'], + 'false', + ); + }, + ); + test('native admonition properties survive edits save and reopen', () async { + final directory = await Directory.systemTemp.createTemp( + 'admonition-properties-', + ); + addTearDown(() => directory.delete(recursive: true)); + final c = BusyMarkWysiwygDocumentController( + document: parse('> A **note**.\n{style="note"}\n'), + ); + addTearDown(c.dispose); + final block = c.document.blocks.first; + expect(c.updateWritersideProperty(block.id, 'id', 'note-id'), isTrue); + expect(c.updateWritersideProperty(block.id, 'filter', 'release'), isTrue); + final file = File('${directory.path}/note.md'); + await file.writeAsString(c.markdown); + final reopened = parse(await file.readAsString()).blocks.first; + expect(reopened.attributes['id'], 'note-id'); + expect(reopened.attributes['filter'], 'release'); + expect(reopened.attributes['style'], 'note', reason: c.markdown); + expect(c.updateWritersideProperty(block.id, 'id', null), isTrue); + expect(parse(c.markdown).blocks.first.attributes['id'], isNull); + }); + test( + 'native heading and paragraph attributes cannot replace projection metadata', + () { + for (final source in [ + '## Heading {level="6" element="topic" id="before"}\n', + 'Paragraph {element="topic" language="opaque" id="before"}\n', + 'Paragraph {x.y="opaque" id="before"}\n', + ]) { + final c = BusyMarkWysiwygDocumentController(document: parse(source)); + final block = c.document.blocks.first; + expect(block.attributes['element'], isNull); + if (block.kind == BusyBlockKind.heading) { + expect(block.attributes['level'], '2'); + } + expect(c.updateWritersideProperty(block.id, 'id', 'after'), isTrue); + expect(c.markdown, source.replaceFirst('id="before"', 'id="after"')); + c.updateBlockText(block.id, 'Changed'); + if (source.contains('element=')) { + expect(c.markdown, contains('element="topic"')); + } + if (block.kind == BusyBlockKind.heading) { + expect(c.markdown, startsWith('## Changed')); + expect(c.markdown, contains('level="6"')); + } else if (source.contains('language=')) { + expect(c.markdown, contains('language="opaque"')); + } else { + expect(c.markdown, contains('x.y="opaque"')); + } + } + }, + ); + test('native code attributes preserve authored projection names', () { + const source = + '```dart\nfinal answer = 42;\n```\n{language="opaque" element="p" level="9" id="before"}\n'; + final c = BusyMarkWysiwygDocumentController(document: parse(source)); + final block = c.document.blocks.first; + expect(block.attributes['language'], 'dart'); + expect(block.attributes['element'], isNull); + expect(c.updateWritersideProperty(block.id, 'id', 'after'), isTrue); + expect(c.markdown, source.replaceFirst('id="before"', 'id="after"')); + c.applyCodeBlockLanguage(block.id, 'java'); + final reopened = parse(c.markdown).blocks.first; + expect(reopened.attributes['language'], 'java'); + expect(c.markdown, contains('language="opaque" element="p" level="9"')); + expect( + WritersideEditingAdapter.sourceAttributes(reopened)['language'], + 'opaque', + ); + c.updateBlockText(block.id, 'final answer = 43;'); + expect(c.markdown, contains('language="opaque" element="p" level="9"')); + expect(parse(c.markdown).blocks.first.attributes['language'], 'java'); + }); + test( + 'native language editing updates an authored override and resets it', + () { + const source = '```java\nhello\n```\n{id="code" lang="text"}\n'; + final c = BusyMarkWysiwygDocumentController(document: parse(source)); + final block = c.document.blocks.first; + expect(block.attributes['language'], 'text'); + expect(c.updateWritersideProperty(block.id, 'language', 'dart'), isTrue); + expect(c.markdown, source.replaceFirst('lang="text"', 'lang="dart"')); + expect(parse(c.markdown).blocks.first.attributes['language'], 'dart'); + expect(c.updateWritersideProperty(block.id, 'language', null), isTrue); + expect(parse(c.markdown).blocks.first.attributes['language'], 'java'); + expect(c.markdown, '```java\nhello\n```\n{id="code"}\n'); + }, + ); + test( + 'ignored native href is inspectable and a destination edit replaces it safely', + () { + const source = + 'Read [**one**][guide]{href="ignored.topic" id="link"}.\n\n[guide]: first.topic\n'; + final c = BusyMarkWysiwygDocumentController(document: parse(source)); + final block = c.document.blocks.first; + final target = busyInlineReferenceRanges(block.inlines).first; + expect(target.destination, 'first.topic'); + expect(target.attributes['busymark-ignored-href'], 'ignored.topic'); + expect( + c.updateInlineReference(block.id, target, 'href', 'second.topic'), + isTrue, + ); + expect( + c.markdown, + source.replaceFirst( + '[**one**][guide]{href="ignored.topic" id="link"}', + '[**one**](second.topic){id="link"}', + ), + ); + }, + ); + test('explicit empty alternative text survives native and XML image edits', () { + for (final xml in [false, true]) { + final c = BusyMarkWysiwygDocumentController( + document: xml + ? const WritersideEditingAdapter().parseXml( + filePath: 't.topic', + source: + 'Logo', + )! + : parse('![Logo](logo.png)\n'), + ); + final image = xml + ? c.document.blocks.first.children.single + : c.document.blocks.first; + expect( + c.updateWritersideProperty(image.id, 'alt', ''), + isTrue, + reason: + 'xml=$xml kind=${image.kind} attrs=${image.attributes} protected=${image.isSourceProtected}', + ); + expect(c.markdown, contains('alt=""')); + final reopened = xml + ? const WritersideEditingAdapter() + .parseXml(filePath: 't.topic', source: c.markdown)! + .blocks + .first + .children + .single + : parse(c.markdown).blocks.first; + expect(reopened.attributes['alt'], ''); + } + }); + test( + 'native image source and alt overrides keep reference definitions local', + () { + const source = + 'Before [![Logo][logo]](guide.md) after.\n\n[logo]: logo.png "Title"\n'; + final c = BusyMarkWysiwygDocumentController(document: parse(source)); + final block = c.document.blocks.first; + BusyInlineStyleRange image() => busyInlineReferenceRanges( + c.document.blocks.first.inlines, + ).firstWhere((r) => r.kind == BusyInlineKind.image); + expect( + c.updateInlineReference(block.id, image(), 'src', 'other.png'), + isTrue, + ); + expect( + c.updateInlineReference(block.id, image(), 'alt', 'Different'), + isTrue, + ); + expect( + c.markdown, + source.replaceFirst( + '[logo]', + '[logo]{src="other.png" alt="Different"}', + ), + ); + final actual = busyInlineReferenceRanges( + parse(c.markdown).blocks.first.inlines, + ).firstWhere((r) => r.kind == BusyInlineKind.image); + expect(actual.destination, 'other.png'); + expect(actual.attributes['alt'], 'Different'); + expect( + c.updateInlineReference(block.id, image(), 'src', ''), + isFalse, + reason: + 'src remains required; reset of a local source override must use its native source', + ); + }, + ); + test( + 'native block image source and alt overrides preserve reference wrappers', + () { + const source = '[![Logo][logo]](guide.md)\n\n[logo]: logo.png\n'; + final c = BusyMarkWysiwygDocumentController(document: parse(source)); + final id = c.document.blocks.first.id; + expect(c.updateWritersideProperty(id, 'src', 'other.png'), isTrue); + expect(c.updateWritersideProperty(id, 'alt', 'Different'), isTrue); + expect( + c.markdown, + source.replaceFirst( + '](guide.md)', + '{src="other.png" alt="Different"}](guide.md)', + ), + ); + final reopened = parse(c.markdown).blocks.first; + expect(reopened.attributes['src'], 'other.png'); + expect(reopened.attributes['alt'], 'Different'); + }, + ); + test( + 'native link destination changes only one occurrence and keeps definitions', + () { + const source = + 'Read [one][guide] and [two][guide].\n\n[guide]: first.topic\n'; + final c = BusyMarkWysiwygDocumentController(document: parse(source)); + final block = c.document.blocks.first; + final target = busyInlineReferenceRanges(block.inlines).first; + expect( + c.updateInlineReference(block.id, target, 'href', 'second.topic'), + isTrue, + ); + expect( + c.markdown, + source.replaceFirst('[one][guide]', '[one](second.topic)'), + ); + final actual = busyInlineReferenceRanges( + parse(c.markdown).blocks.first.inlines, + ); + expect(actual.map((r) => r.destination), ['second.topic', 'first.topic']); + }, + ); + test( + 'inline reference scalar edits retain authored syntax and unrelated bytes', + () { + const source = + 'Before [![Logo][logo]](guide.md) after.\r\n\r\n[logo]: logo.png "Logo title"\r\n'; + final c = BusyMarkWysiwygDocumentController(document: parse(source)); + final block = c.document.blocks.first; + final range = busyInlineReferenceRanges( + block.inlines, + ).firstWhere((r) => r.kind == BusyInlineKind.image); + expect(c.updateInlineReference(block.id, range, 'width', '80'), isTrue); + expect(c.markdown, source.replaceFirst('[logo]', '[logo]{width="80"}')); + final actual = busyInlineReferenceRanges( + parse(c.markdown).blocks.first.inlines, + ).firstWhere((r) => r.kind == BusyInlineKind.image); + expect(actual.attributes['width'], '80'); + }, + ); + test('XML add and reset preserve whitespace before the closing delimiter', () { + for (final closing in [' >', ' />', '\n >']) { + final content = closing.endsWith('/>') ? '' : 'code'; + final source = + ''; + BusyMarkWysiwygDocumentController editor(String text) => + BusyMarkWysiwygDocumentController( + document: const WritersideEditingAdapter().parseXml( + filePath: 't.topic', + source: text, + )!, + ); + final first = editor(source); + expect( + first.updateWritersideProperty( + first.document.blocks.first.children.single.id, + 'collapsible', + 'false', + ), + isTrue, + ); + final reopened = editor(first.markdown); + expect( + reopened.updateWritersideProperty( + reopened.document.blocks.first.children.single.id, + 'collapsible', + null, + ), + isTrue, + ); + expect(reopened.markdown, source); + first.dispose(); + reopened.dispose(); + } + }); + test( + 'embedded XML property edit preserves namespaces, quotes, comments and formatted title', + () { + const source = + "Before.\n\nInstall <b>now</b>

    Body

    \n\nAfter.\n"; + final c = BusyMarkWysiwygDocumentController(document: parse(source)); + final chapter = c.document.blocks.firstWhere( + (b) => b.attributes['element'] == 'chapter', + ); + expect( + c.updateWritersideProperty(chapter.id, 'collapsible', 'false'), + isTrue, + reason: + '${chapter.kind} raw=${chapter.preserveRaw} protected=${chapter.isSourceProtected} ${chapter.attributes}', + ); + expect( + c.markdown, + contains( + "Install <b>now</b>

    Body

    ", + ), + ); + expect(c.markdown, startsWith('Before.\n\n')); + expect(c.markdown, endsWith('After.\n')); + expect(c.markdown, isNot(contains('title=""'))); + expect( + parse(c.markdown).blocks + .firstWhere((b) => b.attributes['element'] == 'chapter') + .attributes['collapsible'], + 'false', + ); + }, + ); + test( + 'inline Markdown images retain their own attributes and link wrappers', + () { + final c = BusyMarkWysiwygDocumentController( + document: parse( + 'One ![first](one.png) then [![second](two.png){width="120"}](guide.md).\n', + ), + ); + final p = c.document.blocks.first; + final images = busyInlineReferenceRanges( + p.inlines, + ).where((r) => r.kind == BusyInlineKind.image).toList(); + expect(images, hasLength(2)); + expect(images.first.attributes['width'], isNull); + expect(images.last.attributes['width'], '120'); + expect( + c.updateInlineReference(p.id, images.first, 'width', '80'), + isTrue, + ); + final reopened = parse(c.markdown); + final actual = busyInlineReferenceRanges( + reopened.blocks.first.inlines, + ).where((r) => r.kind == BusyInlineKind.image).toList(); + expect(actual.map((r) => r.attributes['width']), ['80', '120']); + expect( + reopened.blocks.first.inlines.any((i) => i.kind == BusyInlineKind.link), + isTrue, + ); + expect( + c.updateInlineReference( + p.id, + busyInlineReferenceRanges( + c.document.blocks.first.inlines, + ).firstWhere((r) => r.kind == BusyInlineKind.image), + 'width', + '', + ), + isTrue, + ); + expect( + busyInlineReferenceRanges( + parse(c.markdown).blocks.first.inlines, + ).firstWhere((r) => r.kind == BusyInlineKind.image).attributes['width'], + isNull, + ); + }, + ); + test( + 'switching file source preserves inline code and rejects mismatched content', + () { + final c = BusyMarkWysiwygDocumentController( + document: parse('```text\nkeep me\n```\n'), + ); + final code = c.document.blocks.first; + expect(c.updateWritersideProperty(code.id, 'src', 'sample.txt'), isFalse); + expect(c.markdown, contains('keep me')); + final f = BusyMarkWysiwygDocumentController( + document: parse( + '```text\n```\n{src="sample.txt" include-lines="1-2"}\n', + ), + ); + expect( + f.updateWritersideProperty(f.document.blocks.first.id, 'src', null), + isTrue, + ); + expect(parse(f.markdown).blocks.first.attributes['src'], isNull); + expect(parse(f.markdown).blocks.first.attributes['include-lines'], '1-2'); + }, + ); + test( + 'Topic navigation distinguishes absence, empty, none and scoped declarations', + () { + const source = '

    Text

    '; + final c = BusyMarkWysiwygDocumentController( + document: const WritersideEditingAdapter().parseXml( + filePath: 't.topic', + source: source, + )!, + ); + expect(c.updateTopicNavigation('for', ''), isTrue); + expect(c.markdown, contains('')); + expect(c.updateTopicNavigation('for', 'none'), isTrue); + expect(c.markdown, contains('for="none"')); + expect(c.resetTopicNavigation(), isTrue); + expect(c.markdown, source); + const scoped = + '

    Text

    '; + final conditional = BusyMarkWysiwygDocumentController( + document: const WritersideEditingAdapter().parseXml( + filePath: 't.topic', + source: scoped, + )!, + ); + expect(conditional.updateTopicNavigation('for', 'none'), isFalse); + expect(conditional.markdown, scoped); + }, + ); + + test( + 'paragraph attribute edit preserves reference syntax, Unicode and CRLF', + () { + const source = + 'Read **caf\u00e9** [here][ref].\r\n\r\n[ref]: guide.md "Guide"\r\n'; + final editor = BusyMarkWysiwygDocumentController(document: parse(source)); + final paragraph = editor.document.blocks.first; + expect( + editor.updateWritersideProperty(paragraph.id, 'id', 'intro'), + isTrue, + ); + expect( + editor.markdown, + contains('Read **caf\u00e9** [here][ref].\r\n{id="intro"}'), + ); + expect(editor.markdown, contains('[ref]: guide.md "Guide"\r\n')); + expect( + parse( + editor.markdown, + ).blocks.first.inlines.any((i) => i.kind == BusyInlineKind.strong), + isTrue, + ); + expect(editor.updateWritersideProperty(paragraph.id, 'id', null), isTrue); + expect(editor.markdown, source); + }, + ); + test( + 'native Markdown image scalar edits preserve link and reference syntax', + () { + const source = '[![Logo][logo]](guide.md)\n\n[logo]: logo.png\n'; + final editor = BusyMarkWysiwygDocumentController(document: parse(source)); + final image = editor.document.blocks.firstWhere( + (b) => b.kind == BusyBlockKind.image, + ); + expect(editor.updateWritersideProperty(image.id, 'width', '240'), isTrue); + expect( + editor.markdown, + contains('[![Logo][logo]{width="240"}](guide.md)'), + ); + final reopened = parse( + editor.markdown, + ).blocks.firstWhere((b) => b.kind == BusyBlockKind.image); + expect(reopened.attributes['width'], '240'); + expect(reopened.inlines.first.kind, BusyInlineKind.link); + }, + ); + test( + 'XML inline image edits preserve its link wrapper and surrounding source', + () { + const source = + '

    Before Logo after.

    '; + final editor = BusyMarkWysiwygDocumentController( + document: const WritersideEditingAdapter().parseXml( + filePath: 't.topic', + source: source, + )!, + ); + final paragraph = editor.document.blocks.first.children.first; + final image = busyInlineReferenceRanges( + paragraph.inlines, + ).firstWhere((r) => r.kind == BusyInlineKind.image); + expect( + editor.updateInlineReference(paragraph.id, image, 'width', '240'), + isTrue, + ); + final saved = editor.markdown; + expect( + saved, + contains( + 'Logo', + ), + ); + expect(saved, contains(' after.')); + final reopened = const WritersideEditingAdapter().parseXml( + filePath: 't.topic', + source: saved, + )!; + expect( + busyInlineReferenceRanges( + reopened.blocks.first.children.first.inlines, + ).firstWhere((r) => r.kind == BusyInlineKind.image).attributes['width'], + '240', + ); + }, + ); + test('escaped scalar values decode after a native fence round trip', () { + final editor = BusyMarkWysiwygDocumentController( + document: parse('```text\ncode\n```\n'), + ); + final code = editor.document.blocks.first; + const title = 'A "title" &
    '; + expect( + editor.updateWritersideProperty(code.id, 'collapsed-title', title), + isTrue, + ); + expect( + parse(editor.markdown).blocks.first.attributes['collapsed-title'], + title, + ); + }); + for (final property in { + 'id': 'sample', + 'switcher-key': 'linux', + 'collapsible': 'false', + 'include-lines': '1-2', + 'emphasize-lines': '1', + 'default-state': 'expanded', + }.entries) { + test('fenced code ${property.key} survives editing and reopening', () { + final editor = BusyMarkWysiwygDocumentController( + document: parse('```dart\na\nb\n```\n'), + ); + final code = editor.document.blocks.firstWhere( + (b) => b.kind == BusyBlockKind.codeBlock, + ); + expect( + editor.updateWritersideProperty(code.id, property.key, property.value), + isTrue, + ); + final reopened = parse(editor.markdown); + expect( + reopened.blocks.where((b) => b.kind == BusyBlockKind.codeBlock), + hasLength(1), + ); + expect( + reopened.blocks + .firstWhere((b) => b.kind == BusyBlockKind.codeBlock) + .attributes[property.key], + property.value, + ); + }); + } + test('a file-backed fence does not author collapse', () { + final editor = BusyMarkWysiwygDocumentController( + document: parse('```dart\n\n```\n'), + ); + expect( + editor.updateWritersideProperty( + editor.document.blocks + .firstWhere((b) => b.kind == BusyBlockKind.codeBlock) + .id, + 'src', + 'sample.dart', + ), + isTrue, + ); + final reopened = parse(editor.markdown); + expect( + reopened.blocks + .firstWhere((b) => b.kind == BusyBlockKind.codeBlock) + .attributes['src'], + 'sample.dart', + ); + expect( + reopened.blocks + .firstWhere((b) => b.kind == BusyBlockKind.codeBlock) + .attributes['collapsible'], + isNull, + ); + }); + test('paragraph ID preserves Markdown inline formatting', () { + final editor = BusyMarkWysiwygDocumentController( + document: parse('Hello **world**.\n'), + ); + expect( + editor.updateWritersideProperty( + editor.document.blocks.single.id, + 'id', + 'intro', + ), + isTrue, + ); + expect(editor.markdown, contains('Hello **world**.')); + expect(parse(editor.markdown).blocks.single.attributes['id'], 'intro'); + }); + test('explicit false survives heading serialization', () { + final editor = BusyMarkWysiwygDocumentController( + document: parse('## Chapter\n'), + ); + expect( + editor.updateWritersideProperty( + editor.document.blocks.single.id, + 'collapsible', + 'false', + ), + isTrue, + ); + expect( + parse(editor.markdown).blocks.single.attributes['collapsible'], + 'false', + ); + }); + test('optional enum removal is separate from setting a value', () { + final editor = BusyMarkWysiwygDocumentController( + document: parse('## Chapter {default-state="expanded"}\n'), + ); + expect( + editor.updateWritersideProperty( + editor.document.blocks.single.id, + 'default-state', + '', + ), + isTrue, + ); + expect( + parse(editor.markdown).blocks.single.attributes['default-state'], + isNull, + ); + }); +} diff --git a/test/src/writerside_property_contract_test.dart b/test/src/writerside_property_contract_test.dart new file mode 100644 index 00000000..9e466c69 --- /dev/null +++ b/test/src/writerside_property_contract_test.dart @@ -0,0 +1,1180 @@ +import 'package:busymark/l10n/generated/app_localizations.dart'; +import 'package:busymark/src/app/busymark_design.dart'; +import 'package:busymark/src/editor/wysiwyg/writerside_editing_adapter.dart'; +import 'package:busymark/src/editor/wysiwyg/writerside_inspector_target.dart'; +import 'package:busymark/src/editor/wysiwyg/writerside_properties.dart'; +import 'package:busymark/src/editor/wysiwyg/writerside_property_session.dart'; +import 'package:busymark/src/editor/wysiwyg/wysiwyg_document_controller.dart'; +import 'package:busymark/src/markdown/busymark_document.dart'; +import 'package:busymark/src/markdown/markdown_model.dart'; +import 'package:busymark/src/markdown/markdown_parser.dart'; +import 'package:busymark/src/writerside/writerside_schema.dart'; +import 'package:busymark/src/writerside/writerside_parsers.dart'; +import 'package:busymark/src/writerside/writerside_property_validation.dart'; +import 'package:busymark/src/writerside/writerside_effective_properties.dart'; +import 'package:busymark/src/writerside/writerside_model.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +BusyDocument parse(String source, {bool xml = true, String? file}) => xml + ? const WritersideEditingAdapter().parseXml( + filePath: file ?? 'identity.topic', + source: source, + )! + : const MarkdownParser() + .parse( + filePath: file ?? 'identity.md', + source: source, + mode: MarkdownMode.writersideMarkdown, + validateLocalReferences: false, + ) + .busyDocument; +Iterable walk(List blocks) sync* { + for (final block in blocks) { + yield block; + yield* walk(block.children); + } +} + +Finder entry(String label) => find.descendant( + of: find.byWidgetPredicate( + (w) => w is BusyMarkGroupedTextEntry && w.label == label, + ), + matching: find.byType(TextField), +); +Future edit(WidgetTester tester, String label, String value) async { + await tester.ensureVisible(entry(label)); + await tester.enterText(entry(label), value); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pumpAndSettle(); +} + +Future section(WidgetTester tester, String title) async { + final tile = find.byWidgetPredicate( + (w) => w is TextButton && w.key == ValueKey('writerside-disclosure-$title'), + ); + await tester.ensureVisible(tile); + await tester.tap(tile); + await tester.pumpAndSettle(); +} + +List fields(WidgetTester tester) => tester + .widgetList(find.byType(BusyMarkGroupedTextEntry)) + .map((w) => w.label) + .toList(); + +void main() { + for (final rtl in [false, true]) { + testWidgets( + 'minimum-width scaled enum units retain values, help and native editing (RTL=$rtl)', + (tester) async { + tester.platformDispatcher.textScaleFactorTestValue = 1.5; + addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); + final c = BusyMarkWysiwygDocumentController( + document: parse('# Topic\n\n## Chapter\n', xml: false), + ); + final session = WritersidePropertySession(); + addTearDown(c.dispose); + addTearDown(session.dispose); + final original = c.markdown; + await tester.pumpWidget( + harness( + c, + session, + 'chapter', + width: 260, + direction: rtl ? TextDirection.rtl : TextDirection.ltr, + ), + ); + await tester.pumpAndSettle(); + expect(find.text('Collapsible (collapsible)'), findsOneWidget); + expect(find.text('Use default'), findsOneWidget); + expect(find.text('Default: Disabled'), findsOneWidget); + final caption = tester.getRect(find.text('Collapsible (collapsible)')); + final value = tester.getRect(find.text('Use default')); + expect(value.top, greaterThanOrEqualTo(caption.bottom)); + expect(value.width, lessThanOrEqualTo(228)); + expect(tester.takeException(), isNull); + final hints = tester + .widgetList(find.byType(Semantics)) + .map((w) => w.properties.hint ?? '') + .join('\n'); + expect(hints, contains('Effective: Disabled — built-in default')); + expect(c.markdown, original); + await tester.tap(find.text('Collapsible (collapsible)')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Enabled')); + await tester.pumpAndSettle(); + expect(c.markdown, contains('collapsible="true"')); + expect(c.document.blocks.last.attributes['collapsible'], 'true'); + expect(tester.takeException(), isNull); + }, + ); + } + for (final sample in [ + (true, 'topic', '', false), + ( + true, + 'topic', + '', + true, + ), + ( + true, + 'chapter', + '', + false, + ), + ( + true, + 'p', + '

    Body

    ', + false, + ), + (false, 'topic', '# Topic\n\nBody\n', false), + (false, 'topic', '---\nswitcher-label: Output\n---\n# Topic\n', true), + ( + false, + 'chapter', + '---\nswitcher-label: Other\n---\n# Topic\n\n## Chapter\n', + false, + ), + (false, 'topic', '# Topic {id="explicit"}\n', true), + (false, 'chapter', '# Topic\n\n## Chapter {title="Override"}\n', true), + ( + true, + 'topic', + '', + true, + ), + ]) { + testWidgets('Advanced status follows authored owner: $sample', ( + tester, + ) async { + final c = BusyMarkWysiwygDocumentController( + document: parse(sample.$3, xml: sample.$1), + ); + final session = WritersidePropertySession(); + addTearDown(c.dispose); + addTearDown(session.dispose); + final original = c.markdown; + await tester.pumpWidget(harness(c, session, sample.$2)); + await tester.pumpAndSettle(); + expect( + find.text('Authored settings present'), + sample.$4 ? findsOneWidget : findsNothing, + ); + await section(tester, 'Advanced'); + expect(c.markdown, original); + expect(session.hasPending, isFalse); + // Dividers separate property units, never plain captions or notices. + for (final group in tester.widgetList( + find.byType(BusyMarkGroupedList), + )) { + expect( + group.children.any((w) => w is Text || w is TextButton), + isFalse, + ); + } + if (sample.$2 == 'topic') { + final groups = tester.widgetList( + find.byType(BusyMarkGroupedList), + ); + expect( + groups.where((g) => g.title == 'Document settings'), + hasLength(1), + ); + expect( + groups.where((g) => g.title == 'Heading attributes'), + sample.$1 ? isEmpty : hasLength(1), + ); + final identity = tester.widget( + find.byWidgetPredicate( + (w) => w is BusyMarkActionRow && w.title == 'Topic ID', + ), + ); + expect(identity.onTap, isNull); + expect(identity.subtitleWidget, isA()); + final details = find.text( + 'The pinned builder does not publish topic H1 attributes: inline attributes render literally and separate-line attributes are ignored. These belong to the heading, not the XML root.', + ); + expect(details, findsNothing); + if (!sample.$1) { + expect(find.text('Not published by this builder.'), findsWidgets); + final hints = tester + .widgetList(find.byType(Semantics)) + .map((w) => w.properties.hint ?? '') + .join('\n'); + expect( + hints, + contains('These belong to the heading, not the XML root.'), + ); + } + } + }); + } + testWidgets( + 'mixed native item properties explain protection and keep parent access', + (tester) async { + final c = BusyMarkWysiwygDocumentController( + document: parse('# Topic\n\n- [x] Done\n- Plain\n', xml: false), + ); + final session = WritersidePropertySession(); + addTearDown(c.dispose); + addTearDown(session.dispose); + final original = c.markdown; + await tester.pumpWidget(harness(c, session, 'li')); + await tester.pumpAndSettle(); + expect( + find.text( + 'This element is protected. Edit its source to change properties.', + ), + findsOneWidget, + ); + expect( + tester.widget(entry('Element anchor (id)')).readOnly, + isTrue, + ); + final selector = tester.widget>( + find.byWidgetPredicate( + (w) => w is BusyMarkComboRow && w.title == 'Element', + ), + ); + expect(selector.values, contains(c.document.blocks[1].id)); + expect(c.markdown, original); + expect(session.hasPending, isFalse); + }, + ); + for (final format in ['xml', 'embedded', 'native']) { + testWidgets( + '$format list form edits the container and retains item ownership', + (tester) async { + final c = BusyMarkWysiwygDocumentController( + document: parse( + format == 'xml' + ? '
  • Item

  • ' + : format == 'embedded' + ? '# Topic\n\n
  • Item

  • \n' + : '# Topic\n\n7. Item\n8. Sibling\n', + xml: format == 'xml', + ), + ); + final session = WritersidePropertySession(); + addTearDown(c.dispose); + addTearDown(session.dispose); + await tester.pumpWidget(harness(c, session, 'list')); + await tester.pumpAndSettle(); + expect(fields(tester).toSet(), { + 'Element anchor (id)', + 'Starting number (start)', + 'Columns (columns)', + }); + final types = tester.widget>( + find.byWidgetPredicate( + (w) => + w is BusyMarkComboRow && w.title == 'List type (type)', + ), + ); + expect(types.values.toSet(), { + '', + 'bullet', + 'decimal', + 'alpha-lower', + 'checkbox', + 'none', + }); + expect(types.labelFor('alpha-lower'), 'Lettered list'); + expect(types.labelFor('checkbox'), 'Checkbox list'); + final original = c.markdown; + expect(entry('Filter (filter)'), findsNothing); + await section(tester, 'Advanced'); + expect(entry('Filter (filter)'), findsOneWidget); + expect(entry('Help instances (instance)'), findsOneWidget); + expect(c.markdown, original); + await edit(tester, 'Element anchor (id)', 'list-anchor'); + await edit(tester, 'Columns (columns)', '2'); + final owner = walk( + c.document.blocks, + ).singleWhere((b) => b.attributes['element'] == 'list'); + expect(owner.attributes['id'], 'list-anchor'); + expect(owner.attributes['columns'], '2'); + expect( + owner.children.first.attributes['id'], + format == 'native' ? isNull : 'item', + ); + final reopened = parse(c.markdown, xml: format == 'xml'); + final saved = walk( + reopened.blocks, + ).singleWhere((b) => b.attributes['element'] == 'list'); + expect(saved.attributes['id'], 'list-anchor'); + expect(saved.attributes['columns'], '2'); + expect( + saved.children.first.plainText, + format == 'native' ? 'Item' : '', + ); + if (format == 'native') expect(c.markdown, isNot(contains(' show({ + bool visible = true, + String identity = 'buffer-a', + }) async { + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: PageStorage( + bucket: bucket, + child: visible + ? BusyMarkWritersideProperties( + documentIdentity: identity, + document: c.document, + path: c.elementPath(target.id), + target: target, + topicSelected: false, + session: session, + onTargetSelected: (_) {}, + onClose: () {}, + onProperty: (_, _) => true, + onHeadingText: (_) => true, + onTopicProperty: (_, _) => true, + onManageItem: (_, _, _) {}, + ) + : const Text('Selection unavailable'), + ), + ), + ), + ); + await tester.pumpAndSettle(); + } + + Finder caps() => find.byWidgetPredicate( + (w) => + w is BusyMarkComboRow && w.title == 'Capitalization (caps)', + ); + await show(); + expect(caps(), findsNothing); + await section(tester, 'Advanced'); + expect(caps(), findsOneWidget); + expect( + tester.widget>(caps()).subtitle, + 'Default: As written', + ); + await show(visible: false); + expect(find.text('Selection unavailable'), findsOneWidget); + await show(); + expect(caps(), findsOneWidget); + await show(identity: 'buffer-b'); + expect(caps(), findsNothing); + await show(); + expect(caps(), findsOneWidget); + expect(c.markdown, '# Topic\n\n## Heading\n'); + }, + ); + testWidgets( + 'normal H2 presentation keeps generated anchor derived through focus, help, save and overrides', + (tester) async { + const heading = '2. Diagnosed cause and source map'; + const source = '# Topic\n\n## $heading\n\nBody.\n'; + final c = BusyMarkWysiwygDocumentController( + document: parse(source, xml: false), + ); + final session = WritersidePropertySession(); + addTearDown(c.dispose); + addTearDown(session.dispose); + Future show() async { + await tester.pumpWidget(harness(c, session, 'chapter')); + await tester.pumpAndSettle(); + } + + await show(); + final selector = tester.widget>( + find.byWidgetPredicate( + (w) => w is BusyMarkComboRow && w.title == 'Element', + ), + ); + expect(selector.labelFor(selector.selected), 'Chapter'); + expect( + find.text('Select a single element to inspect its properties.'), + findsNothing, + ); + expect( + find.byKey(const ValueKey('writerside-properties-identity')), + findsNothing, + ); + expect(fields(tester), ['Heading text', 'Element anchor (id)']); + expect( + tester.widget(entry('Element anchor (id)')).controller!.text, + isEmpty, + ); + expect( + find.text('Generated anchor; setting a value authors an override.'), + findsNothing, + ); + final generated = find.byKey( + const ValueKey('writerside-generated-anchor'), + ); + expect( + tester.widget(generated).data, + 'Generated: 2-diagnosed-cause-and-source-map', + ); + final group = tester + .element(generated) + .findAncestorWidgetOfExactType()!; + expect(group.children.whereType(), isEmpty); + expect(group.children.whereType(), isEmpty); + await tester.tap(entry('Element anchor (id)')); + await tester.pumpAndSettle(); + await tester.sendKeyEvent(LogicalKeyboardKey.f1); + await tester.pump(); + expect( + find.text('Generated anchor; setting a value authors an override.'), + findsOneWidget, + ); + expect(session.flush(), isTrue); + expect(c.markdown, source); + await tester.pump(const Duration(seconds: 3)); + await tester.tap(entry('Heading text')); + await tester.pumpAndSettle(); + expect(c.markdown, source); + await section(tester, 'Advanced'); + await section(tester, 'Advanced'); + expect(c.markdown, source); + await edit(tester, 'Element anchor (id)', 'cause-map'); + expect(c.markdown, contains('id="cause-map"')); + expect(generated, findsNothing); + await edit(tester, 'Element anchor (id)', ''); + expect(c.markdown, source); + // The workspace reparses published source before exposing derived IDs. + c.replaceDocument(parse(c.markdown, xml: false)); + await show(); + expect(generated, findsOneWidget); + final reopened = BusyMarkWysiwygDocumentController( + document: parse(c.markdown, xml: false), + ); + addTearDown(reopened.dispose); + await tester.pumpWidget(harness(reopened, session, 'chapter')); + await tester.pumpAndSettle(); + expect(generated, findsOneWidget); + expect(reopened.markdown, source); + }, + ); + testWidgets( + 'long generated anchor wraps at minimum and desktop widths with text scaling', + (tester) async { + final longHeading = List.filled(14, 'documentation').join(' '); + final c = BusyMarkWysiwygDocumentController( + document: parse('# Topic\n\n## $longHeading\n', xml: false), + ); + final session = WritersidePropertySession(); + addTearDown(c.dispose); + addTearDown(session.dispose); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); + tester.view.devicePixelRatio = 1; + for (final width in [260.0, 390.0]) { + tester.view.physicalSize = Size(width, 1500); + tester.platformDispatcher.textScaleFactorTestValue = 1.6; + await tester.pumpWidget(harness(c, session, 'chapter')); + await tester.pumpAndSettle(); + final generated = find.byKey( + const ValueKey('writerside-generated-anchor'), + ); + await tester.ensureVisible(generated); + final text = tester.widget(generated); + expect( + text.data, + 'Generated: ${List.filled(14, 'documentation').join('-')}', + ); + expect(text.maxLines, isNull); + expect(tester.getSize(generated).height, greaterThan(32)); + expect(tester.takeException(), isNull); + expect(c.markdown, isNot(contains('id='))); + } + }, + ); + testWidgets( + 'effective subtitles are compact and provenance remains in semantics', + (tester) async { + final semantics = tester.ensureSemantics(); + const module = WritersideEffectiveProperties( + settings: WritersideSettingsConfig( + defaultProperties: [ + WritersideDefaultProperty( + elementName: 'chapter', + propertyName: 'collapsible', + value: 'true', + ), + ], + ), + ); + for (final row in [ + ( + attrs: '', + effective: const WritersideEffectiveProperties(), + selected: '', + summary: 'Default: Disabled', + origin: 'built-in default', + ), + ( + attrs: '', + effective: module, + selected: '', + summary: 'Default: Enabled · Module', + origin: 'module default', + ), + ( + attrs: ' collapsible="true"', + effective: module, + selected: 'true', + summary: null, + origin: 'element override', + ), + ( + attrs: ' collapsible="%collapse%"', + effective: const WritersideEffectiveProperties( + variables: {'collapse': 'true'}, + ), + selected: '%collapse%', + summary: 'Resolved: Enabled', + origin: 'element override', + ), + ( + attrs: ' collapsible="%unknown%"', + effective: const WritersideEffectiveProperties(), + selected: '%unknown%', + summary: 'Unresolved', + origin: 'unresolved', + ), + ]) { + final c = BusyMarkWysiwygDocumentController( + document: parse( + '

    Body

    ', + ), + ); + final session = WritersidePropertySession(); + await tester.pumpWidget( + harness(c, session, 'chapter', effective: row.effective), + ); + await tester.pumpAndSettle(); + final finder = find.byWidgetPredicate( + (w) => + w is BusyMarkComboRow && + w.title == 'Collapsible (collapsible)', + ); + final combo = tester.widget>(finder); + expect(combo.selected, row.selected); + expect(combo.subtitle, row.summary); + expect(combo.labelFor(''), 'Use default'); + expect(combo.tooltip, contains(row.origin)); + expect( + tester.getSemantics(finder).getSemanticsData().hint, + contains(row.origin), + ); + expect( + find.byWidgetPredicate( + (w) => w is Text && w.data?.startsWith('Effective:') == true, + ), + findsNothing, + ); + await tester.pumpWidget(const SizedBox()); + session.dispose(); + c.dispose(); + } + final c = BusyMarkWysiwygDocumentController( + document: parse( + '

    Body

    ', + ), + ); + final session = WritersidePropertySession(); + addTearDown(c.dispose); + addTearDown(session.dispose); + await tester.pumpWidget(harness(c, session, 'def')); + await tester.pumpAndSettle(); + final state = tester.widget>( + find.byWidgetPredicate( + (w) => + w is BusyMarkComboRow && + w.title == 'Published initial state (default-state)', + ), + ); + expect(state.selected, 'inherited'); + expect(state.subtitle, 'Resolved: Expanded'); + expect(state.tooltip, contains('parent element')); + semantics.dispose(); + }, + ); + test( + 'formal authoring sets are independent of preservation capabilities', + () { + expect(WritersideSchema.attributesFor('topic').toSet(), { + 'title', + 'id', + 'help-id', + 'switcher-label', + 'ignore-vars', + 'is-library', + 'last-modified', + }); + expect(WritersideSchema.attributesFor('p').toSet(), { + 'id', + 'switcher-key', + 'filter', + 'help-id', + 'ignore-vars', + 'instance', + }); + expect(WritersideSchema.attributesFor('chapter').toSet(), { + 'title', + 'id', + 'level', + 'caps', + 'collapsible', + 'default-state', + 'switcher-key', + 'filter', + 'help-id', + 'ignore-vars', + 'instance', + }); + expect(WritersideSchema.attributesFor('li'), contains('checked')); + expect(WritersideSchema.valuesFor('table', 'sticky-header'), { + 'true', + 'false', + }); + expect(WritersideSchema.valuesFor('table', 'sortable'), { + 'true', + 'false', + }); + expect(WritersideSchema.valuesFor('td', 'sortable'), {'true', 'false'}); + expect( + WritersideSchema.attributesFor('code-block'), + contains('emphasize-lines'), + ); + for (final name in ['filter', 'instance', 'switcher-key']) { + expect(validateWritersideProperty('topic', name, 'value'), isNotNull); + expect( + WritersideSchema.preservedAttributesFor('topic'), + contains(name), + ); + } + }, + ); + test( + 'unsupported root attributes survive default-title edits; identity is not scalar', + () { + const source = + 'Formatted <b>title</b>

    Body

    '; + final c = BusyMarkWysiwygDocumentController(document: parse(source)); + addTearDown(c.dispose); + final root = c.document.blocks.first; + for (final name in ['filter', 'instance', 'switcher-key', 'id']) { + expect(c.updateWritersideProperty(root.id, name, 'new'), isFalse); + } + expect(c.updateWritersideProperty(root.id, 'title', 'After'), isTrue); + for (final retained in [ + 'id="mismatch"', + 'filter="legacy"', + 'instance="web"', + 'switcher-key="One"', + 'odd="keep"', + '', + 'title', + ]) { + expect(c.markdown, contains(retained)); + } + expect(c.markdown, contains('title="After"')); + }, + ); + test( + 'logical topic resolves one H1 and missing/unsaved identity stays absent', + () { + final d = parse('# First {id="anchor"}\n\n# Later\n', xml: false); + final headings = d.blocks + .where((b) => b.kind == BusyBlockKind.heading) + .toList(); + final first = WritersideInspectorTarget.fromSelection( + bufferId: 'b', + revision: 1, + source: d.source!, + document: d, + blockId: headings.first.id, + ); + final later = WritersideInspectorTarget.fromSelection( + bufferId: 'b', + revision: 1, + source: d.source!, + document: d, + blockId: headings.last.id, + ); + expect(first.topic, isTrue); + expect(later.topic, isFalse); + expect(first.block!.attributes['id'], 'anchor'); + expect(first.document.filePath, 'identity.md'); + final missing = parse('Body only\n', xml: false, file: ''); + expect( + WritersideInspectorTarget.fromTopic( + bufferId: 'b', + revision: 1, + source: missing.source!, + document: missing, + ).block, + isNull, + ); + }, + ); + test( + 'navigation insertion/reset preserve CRLF, comments, formatting and unrelated front matter', + () { + const source = + '---\r\ncustom: "keep"\r\n---\r\n\r\n# Heading\r\n\r\n\r\n\r\nBody **formatted** [link](other.md).\r\n'; + final c = BusyMarkWysiwygDocumentController( + document: parse(source, xml: false), + ); + addTearDown(c.dispose); + expect( + c.setTopicNavigation( + present: true, + forElements: 'chapter,tab', + depth: '2', + ), + isTrue, + ); + final inserted = c.markdown; + expect( + inserted.replaceFirst( + '\r\n\r\n', + '', + ), + source, + ); + expect(inserted.replaceAll('\r\n', '').contains('\n'), isFalse); + expect(c.resetTopicNavigation(), isTrue); + expect( + c.markdown.replaceFirst( + '# Heading\r\n\r\n\r\n\r\n', + '# Heading\r\n\r\n', + ), + source, + ); + }, + ); + test('topic instance title metadata never owns a Markdown chapter title', () { + const source = + '# Topic\n\nTopic web\n\n## Chapter\n\nChapter web\n'; + final topic = const WritersideTopicParser().parseMarkdown( + filePath: 'identity.md', + topicsRoot: '.', + source: source, + ); + expect(topic.titleOverrides.map((v) => v.title), ['Topic web']); + expect(topic.document.topicTitleElements.map((v) => v.plainText), [ + 'Topic web', + ]); + expect(topic.document.source, source); + }); + test('reopened Markdown navigation retains its authored owner', () { + final source = + '# Heading {id="anchor"}\n\n\n\n## Chapter\n\nBody\n'; + final d = parse(source, xml: false); + final c = BusyMarkWysiwygDocumentController(document: d); + addTearDown(c.dispose); + expect(c.resetTopicNavigation(), isTrue); + expect( + c.markdown, + source.replaceFirst( + '', + '', + ), + ); + final reopened = BusyMarkWysiwygDocumentController( + document: parse(c.markdown, xml: false), + ); + expect(reopened.setTopicNavigation(present: true, depth: '2'), isTrue); + expect(reopened.markdown, contains('depth="2"')); + reopened.dispose(); + }); + for (final xml in [true, false]) { + test( + 'navigation ${xml ? "XML" : "Markdown"}: distinct states, compound config, protection', + () { + final c = BusyMarkWysiwygDocumentController( + document: parse( + xml + ? '

    Body

    ' + : '# Title\n\nBody\n', + xml: xml, + ), + ); + addTearDown(c.dispose); + expect(c.setTopicNavigation(present: true), isTrue); + expect(c.markdown, contains('')); + expect( + c.setTopicNavigation(present: true, forElements: 'none'), + isTrue, + ); + expect(c.markdown, contains('for="none"')); + expect(c.setTopicNavigation(present: true, depth: '2'), isTrue); + expect(c.markdown, contains('depth="2"')); + expect(c.markdown, isNot(contains('for='))); + expect( + c.setTopicNavigation( + present: true, + forElements: 'chapter,procedure,tab,def', + depth: '3', + ), + isTrue, + ); + final saved = c.markdown; + expect(c.setTopicNavigation(present: true, depth: 'oops'), isFalse); + expect(c.markdown, saved); + expect( + c.setTopicNavigation(present: true, forElements: 'paragraph'), + isFalse, + ); + expect(c.markdown, saved); + expect(c.setTopicNavigation(present: false), isTrue); + expect(c.markdown, isNot(contains('show-structure'))); + for (final markup in [ + '', + '', + ]) { + final protected = BusyMarkWysiwygDocumentController( + document: parse( + xml + ? '$markup

    Body

    ' + : '# Title\n\n$markup\n', + xml: xml, + ), + ); + expect( + protected.setTopicNavigation(present: true, forElements: 'none'), + isFalse, + ); + expect(protected.markdown, contains(markup)); + protected.dispose(); + } + }, + ); + } + for (final tag in ['topic', 'chapter', 'p']) { + testWidgets( + '$tag primary fields and Advanced have independently specified owners', + (tester) async { + final c = BusyMarkWysiwygDocumentController( + document: parse( + '

    A long paragraph.

    ', + ), + ); + addTearDown(c.dispose); + final session = WritersidePropertySession(); + addTearDown(session.dispose); + await tester.pumpWidget(harness(c, session, tag)); + final before = c.markdown; + final selector = tester.widget>( + find.byWidgetPredicate( + (w) => w is BusyMarkComboRow && w.title == 'Element', + ), + ); + expect( + selector.labelFor(selector.selected), + tag == 'p' + ? 'Paragraph' + : tag == 'topic' + ? 'Topic Properties' + : 'Chapter', + ); + expect( + find.text('Select a single element to inspect its properties.'), + findsNothing, + ); + expect( + fields(tester), + tag == 'topic' + ? ['Default title (title)'] + : tag == 'chapter' + ? ['Default title (title)', 'Element anchor (id)'] + : ['Element anchor (id)'], + ); + expect(find.text('Advanced'), findsOneWidget); + expect( + find.text('Topic navigation'), + tag == 'topic' ? findsOneWidget : findsNothing, + ); + expect( + find.text('Authored settings present'), + tag == 'p' ? findsOneWidget : findsNothing, + ); + await section(tester, 'Advanced'); + final actual = fields(tester).toSet(); + expect( + actual, + tag == 'topic' + ? { + 'Default title (title)', + 'Switcher label (switcher-label)', + 'Context help ID (help-id)', + 'Last modified (last-modified)', + } + : tag == 'chapter' + ? { + 'Default title (title)', + 'Element anchor (id)', + 'Filter (filter)', + 'Context help ID (help-id)', + 'Help instances (instance)', + 'Switcher key (switcher-key)', + } + : { + 'Element anchor (id)', + 'Filter (filter)', + 'Context help ID (help-id)', + 'Help instances (instance)', + 'Switcher key (switcher-key)', + }, + ); + expect(c.markdown, before); + if (tag == 'topic') { + expect( + find.byKey(const ValueKey('writerside-topic-id')), + findsOneWidget, + ); + await edit(tester, 'Default title (title)', 'Changed topic'); + expect(c.markdown, contains('id="identity"')); + } else { + await edit(tester, 'Help instances (instance)', '!web,desktop'); + final block = walk( + c.document.blocks, + ).firstWhere((b) => b.attributes['element'] == tag); + expect(block.attributes['instance'], '!web,desktop'); + expect(c.document.blocks.first.attributes['id'], 'identity'); + } + expect(tester.takeException(), isNull); + }, + ); + } + testWidgets( + 'Markdown filename, heading text, heading anchor and title override stay distinct', + (tester) async { + final c = BusyMarkWysiwygDocumentController( + document: parse( + '# Heading words {id="different-anchor"}\n\n## Chapter words {id="chapter" title="Published override"}\n\nBody\n', + xml: false, + ), + ); + addTearDown(c.dispose); + final session = WritersidePropertySession(); + addTearDown(session.dispose); + await tester.pumpWidget(harness(c, session, 'topic')); + expect( + tester + .widget( + find.byKey(const ValueKey('writerside-topic-id')), + ) + .data, + 'identity', + ); + expect(fields(tester), ['Heading text']); + await edit(tester, 'Heading text', 'New heading words'); + expect(c.document.filePath, 'identity.md'); + expect(c.markdown, contains('id="different-anchor"')); + await section(tester, 'Advanced'); + await edit(tester, 'Element anchor (id)', 'new-anchor'); + expect(c.markdown, contains('id="new-anchor"')); + expect(c.document.filePath, 'identity.md'); + session.transition(); + await tester.pumpWidget(harness(c, session, 'chapter')); + await edit(tester, 'Heading text', 'New chapter words'); + expect(c.markdown, contains('title="Published override"')); + if (entry('Title override (title)').evaluate().isEmpty) { + await section(tester, 'Advanced'); + } + await edit(tester, 'Title override (title)', 'New published override'); + expect(c.markdown, contains('New chapter words')); + expect(c.markdown, contains('title="New published override"')); + final reopened = parse(c.markdown, xml: false); + expect( + reopened.blocks + .where((b) => b.kind == BusyBlockKind.heading) + .last + .attributes['title'], + 'New published override', + ); + }, + ); + testWidgets( + 'paragraph has one concise identity and complete accessible content', + (tester) async { + final c = BusyMarkWysiwygDocumentController( + document: parse( + '

    First line
    Second line.

    ', + ), + ); + addTearDown(c.dispose); + final session = WritersidePropertySession(); + addTearDown(session.dispose); + final semantics = tester.ensureSemantics(); + + await tester.pumpWidget(harness(c, session, 'p')); + final selector = tester.widget>( + find.byWidgetPredicate( + (w) => w is BusyMarkComboRow && w.values.contains('@topic'), + ), + ); + expect(selector.labelFor(selector.selected), 'Paragraph'); + final excerpt = find.byKey( + const ValueKey('writerside-properties-identity'), + ); + expect(tester.widget(excerpt).maxLines, 2); + expect( + tester.getSemantics(excerpt).label, + contains('First line\nSecond line.'), + ); + expect( + c.markdown, + '

    First line
    Second line.

    ', + ); + semantics.dispose(); + }, + ); + testWidgets( + 'Custom from every preset keeps drafts compound and refuses invalid transitions', + (tester) async { + final c = BusyMarkWysiwygDocumentController( + document: parse(''), + ); + addTearDown(c.dispose); + final session = WritersidePropertySession(); + addTearDown(session.dispose); + var publications = 0; + await tester.pumpWidget( + harness(c, session, 'topic', onPublished: () => publications++), + ); + await section(tester, 'Topic navigation'); + BusyMarkComboRow combo() => tester.widget( + find.byWidgetPredicate( + (w) => w is BusyMarkComboRow && w.values.contains('custom'), + ), + ); + for (final preset in ['default', 'all', 'none']) { + combo().onSelected(preset); + await tester.pumpAndSettle(); + final before = c.markdown; + combo().onSelected('custom'); + await tester.pumpAndSettle(); + expect(combo().selected, 'custom'); + expect(c.markdown, before); + combo().onSelected(preset); + await tester.pumpAndSettle(); + } + combo().onSelected('custom'); + await tester.pumpAndSettle(); + final start = publications; + await tester.enterText( + entry('Navigation elements (for)'), + 'chapter,tab,def', + ); + await tester.enterText(entry('Navigation depth (depth)'), 'bad'); + expect(session.flush(), isFalse); + expect(publications, start); + expect(session.hasPending, isTrue); + await section(tester, 'Topic navigation'); + expect(entry('Navigation depth (depth)'), findsOneWidget); + await tester.enterText(entry('Navigation depth (depth)'), '2'); + expect(session.flush(), isTrue); + await tester.pumpAndSettle(); + expect(publications, start + 1); + expect(c.markdown, contains('for="chapter,tab,def" depth="2"')); + }, + ); +} + +Widget harness( + BusyMarkWysiwygDocumentController c, + WritersidePropertySession session, + String tag, { + VoidCallback? onPublished, + double? width, + TextDirection? direction, + WritersideEffectiveProperties effective = + const WritersideEffectiveProperties(), +}) => MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: StatefulBuilder( + builder: (context, refresh) { + final block = tag == 'topic' + ? (c.document.isXmlTopic + ? c.document.blocks.first + : WritersideEditingAdapter.topicHeading(c.document)) + : walk(c.document.blocks).firstWhere( + (b) => + b.attributes['element'] == tag || + tag == 'li' && + WritersideEditingAdapter.tagForBlock(b) == 'li' || + tag == 'chapter' && + b.kind == BusyBlockKind.heading && + b.attributes['level'] != '1', + ); + bool apply(bool Function() change) { + final ok = change(); + if (ok) { + onPublished?.call(); + refresh(() {}); + } + return ok; + } + + return SizedBox( + width: width, + child: Directionality( + textDirection: direction ?? Directionality.of(context), + child: BusyMarkWritersideProperties( + effectiveProperties: effective, + document: c.document, + target: block, + path: block == null ? [] : c.elementPath(block.id), + topicSelected: tag == 'topic', + session: session, + onClose: () {}, + onTargetSelected: (_) {}, + onManageItem: (_, _, _) {}, + onProperty: (name, value) => apply( + () => c.updateWritersideProperty(block!.id, name, value), + ), + onHeadingText: (text) => apply(() { + c.updateBlockText(block!.id, text); + return true; + }), + onTopicProperty: (name, value) => + apply(() => c.updateTopicSwitcherLabel(value)), + onNavigation: (present, elements, depth) => apply( + () => c.setTopicNavigation( + present: present, + forElements: elements, + depth: depth, + ), + ), + ), + ), + ); + }, + ), + ), +); diff --git a/test/src/writerside_property_margin_test.dart b/test/src/writerside_property_margin_test.dart new file mode 100644 index 00000000..9b07526d --- /dev/null +++ b/test/src/writerside_property_margin_test.dart @@ -0,0 +1,200 @@ +import 'package:busymark/l10n/generated/app_localizations.dart'; +import 'package:busymark/src/app/busymark_design.dart'; +import 'package:busymark/src/editor/wysiwyg/wysiwyg_editor.dart'; +import 'package:busymark/src/editor/wysiwyg/wysiwyg_block_widgets.dart'; +import 'package:busymark/src/markdown/busymark_document.dart'; +import 'package:busymark/src/markdown/markdown_model.dart'; +import 'package:busymark/src/markdown/markdown_parser.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +BusyDocument parse(String text) => const MarkdownParser() + .parse( + filePath: 'hover.md', + source: text, + mode: MarkdownMode.writersideMarkdown, + validateLocalReferences: false, + ) + .busyDocument; +Widget app(Widget editor, {TextDirection direction = TextDirection.ltr}) => + ProviderScope( + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Directionality( + textDirection: direction, + child: Scaffold(body: editor), + ), + ), + ); +Iterable walk(List blocks) sync* { + for (final b in blocks) { + yield b; + yield* walk(b.children); + } +} + +void main() { + for (final direction in [TextDirection.ltr, TextDirection.rtl]) { + testWidgets( + 'one reachable margin control inspects B independently of A ($direction)', + (tester) async { + await tester.binding.setSurfaceSize(const Size(1400, 1200)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + const source = + '# Topic\n\nCaret A\n\n## Heading B\n\nParagraph B\n\n```text\nCode B\n```\n\n![Image B](missing.png)\n\n- Item B\n - Nested B\n'; + final document = parse(source); + final explicit = []; + var opened = 0; + final changes = []; + await tester.pumpWidget( + app( + BusyMarkWysiwygEditor( + document: document, + documentId: 'd', + contentRevision: 0, + onSourceChanged: (_, s) => changes.add(s), + onPropertiesRequested: () => opened++, + onInspectElement: + ( + _, + id, + inline, + requested, + multiple, { + bool revisionRefresh = false, + }) { + if (requested) { + explicit.add(id); + expect(inline, isNull); + expect(multiple, isFalse); + } + }, + ), + direction: direction, + ), + ); + await tester.pumpAndSettle(); + expect( + find.byKey(const ValueKey('wysiwyg-writerside-properties')), + findsNothing, + ); + final caret = find.byWidgetPredicate( + (w) => w is TextField && w.controller?.text == 'Caret A', + ); + await tester.tap(caret); + await tester.pumpAndSettle(); + final field = tester.widget(caret); + field.controller!.selection = const TextSelection.collapsed(offset: 3); + await tester.pumpAndSettle(); + final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse); + await mouse.addPointer(location: const Offset(1, 1)); + for (final text in [ + 'Heading B', + 'Paragraph B', + 'Code B', + 'Image B', + 'Item B', + 'Nested B', + ]) { + final block = walk( + document.blocks, + ).singleWhere((b) => b.plainText == text); + final target = find.byWidgetPredicate( + (w) => w is BusyMarkWysiwygBlockField && w.block.id == block.id, + ); + final before = tester.getRect(target); + await mouse.moveTo(before.center); + await tester.pumpAndSettle(); + final icon = find.byWidgetPredicate( + (w) => + w is BusyMarkHeaderIconButton && + w.tooltip == 'Element Properties', + ); + expect(icon, findsOneWidget, reason: text); + expect( + tester.getRect(target), + before, + reason: 'Hover must not move $text', + ); + expect(explicit.length, opened); + final region = tester.getRect(icon); + expect( + region.overlaps(before), + isFalse, + reason: 'Properties must not cover $text', + ); + await mouse.moveTo(region.center); + await tester.pumpAndSettle(); + expect( + icon, + findsOneWidget, + reason: 'The margin remains reachable for $text', + ); + await mouse.down(region.center); + await mouse.up(); + await tester.pumpAndSettle(); + expect(explicit.last, block.id); + expect( + field.controller!.selection, + const TextSelection.collapsed(offset: 3), + ); + expect(changes, isEmpty); + expect(opened, explicit.length); + } + await mouse.removePointer(); + }, + ); + } + testWidgets( + 'a captured margin action rejects a replacement after revision change', + (tester) async { + var document = parse('# Topic\n\nCaret A\n\n## Heading B\n'); + var revision = 0; + var opened = 0; + final explicit = []; + Widget host() => app( + BusyMarkWysiwygEditor( + document: document, + documentId: 'd', + contentRevision: revision, + onSourceChanged: (_, _) => fail('Inspection cannot edit source'), + onPropertiesRequested: () => opened++, + onInspectElement: + (_, id, _, requested, _, {bool revisionRefresh = false}) { + if (requested) explicit.add(id); + }, + ), + ); + await tester.pumpWidget(host()); + await tester.pumpAndSettle(); + final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse); + await mouse.addPointer(location: const Offset(1, 1)); + final heading = find.byWidgetPredicate( + (w) => w is TextField && w.controller?.text == 'Heading B', + ); + await mouse.moveTo(tester.getCenter(heading)); + await tester.pumpAndSettle(); + final oldAction = tester + .widget( + find.byWidgetPredicate( + (w) => + w is BusyMarkHeaderIconButton && + w.tooltip == 'Element Properties', + ), + ) + .onPressed!; + document = parse('# Topic\n\nCaret A\n\n## Replacement\n'); + revision++; + await tester.pumpWidget(host()); + await tester.pumpAndSettle(); + oldAction(); + await tester.pumpAndSettle(); + expect(explicit, isEmpty); + expect(opened, 0); + await mouse.removePointer(); + }, + ); +} diff --git a/test/src/writerside_title_regressions_test.dart b/test/src/writerside_title_regressions_test.dart index 0dbafc30..94f19dd3 100644 --- a/test/src/writerside_title_regressions_test.dart +++ b/test/src/writerside_title_regressions_test.dart @@ -9,6 +9,7 @@ import 'package:busymark/src/writerside/writerside_document.dart'; import 'package:busymark/src/writerside/writerside_document_parser.dart'; import 'package:busymark/src/writerside/writerside_parsers.dart'; import 'package:busymark/src/writerside/writerside_title_editor.dart'; +import 'package:busymark/src/writerside/writerside_model.dart'; import 'package:busymark/src/writerside/writerside_topic_creator.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -85,6 +86,42 @@ void main() { ); } + test( + 'instance and TOC title changes preserve qualified owners and unrelated attributes', + () async { + final root = await fixture( + 'OldOther formattedBody', + extension: 'topic', + tree: + '', + ); + final current = await session(root); + await save( + current, + const WritersideTitleEdit( + instanceTitle: 'New & title', + tocTitle: 'New TOC', + ), + ); + final source = await File(current.topic.filePath).readAsString(); + expect( + source, + contains( + 'New & title', + ), + ); + expect( + source, + contains( + 'Other formatted', + ), + ); + expect( + await File(current.treePath).readAsString(), + contains(''), + ); + }, + ); test( 'title edit preserves the resolved topic owner across modules', () async { @@ -399,18 +436,28 @@ void main() { await tester.tap(find.text('Advanced Settings')); await tester.pumpAndSettle(); final field = find.widgetWithText(TextField, "Title for 'guide':"); - expect( - tester.widget(field).controller!.text, - current.topic.titleOverrides - .where((t) => t.instance == 'guide') - .firstOrNull - ?.title ?? - '', - ); + final authored = + current.topic.titleOverrides + .where((t) => t.instance == 'guide') + .firstOrNull + ?.title ?? + ''; + if (current.topic.format == WritersideTopicFormat.xml || + authored.isNotEmpty) { + expect(tester.widget(field).controller!.text, authored); + expect( + tester.widget(field).readOnly, + current.topic.format != WritersideTopicFormat.xml, + ); + } else { + expect(field, findsNothing); + } if (inspectHelp) { expect( find.text( - 'Used for the current instance only. By default, inherited from topic title.', + current.topic.format == WritersideTopicFormat.xml + ? 'Used for the current instance only. By default, inherited from topic title.' + : 'The pinned builder does not publish instance title overrides in Markdown topics. Existing markup is preserved; inspect it in Source.', ), findsOneWidget, ); @@ -441,7 +488,9 @@ void main() { 'https://www.jetbrains.com/help/writerside/topics.html', ); } - if (value != null) await tester.enterText(field, value); + if (value != null && current.topic.format == WritersideTopicFormat.xml) { + await tester.enterText(field, value); + } await tester.pumpAndSettle(); await tester.tap(find.text('OK')); await tester.pumpAndSettle(); @@ -456,9 +505,12 @@ void main() { addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); const example = - '```xml\nFenced & example\n```'; + 'Fenced & example]]>'; final root = (await tester.runAsync( - () => fixture('# Base\n\n$example\n'), + () => fixture( + '$example', + extension: 'topic', + ), ))!; var current = (await tester.runAsync(() => session(root)))!; expect(current.topic.titleOverrides, isEmpty); @@ -492,7 +544,7 @@ void main() { ); testWidgets( - 'single-quoted Markdown override reopens decoded and edits without touching example', + 'single-quoted Markdown override stays inspectable and Source edits preserve escaping and examples', (tester) async { tester.view.physicalSize = const Size(1200, 1000); tester.view.devicePixelRatio = 1; @@ -507,7 +559,18 @@ void main() { final current = (await tester.runAsync(() => session(root)))!; expect(current.topic.titleOverrides.single.title, 'A & B'); final edit = (await showTitle(tester, current, value: 'A & B updated'))!; + expect(edit.instanceTitle, isNull); await tester.runAsync(() => save(current, edit)); + final untouched = (await tester.runAsync(() => session(root)))!; + expect(untouched.topicLoad.text, current.topicLoad.text); + // Source remains available for authored markup; the inspector does not + // advertise this ignored builder mapping as a new supported control. + await tester.runAsync( + () => save( + current, + const WritersideTitleEdit(instanceTitle: 'A & B updated'), + ), + ); final updated = (await tester.runAsync(() => session(root)))!; expect(updated.topic.titleOverrides.single.title, 'A & B updated'); expect(updated.topicLoad.text, contains(example)); diff --git a/test/src/writerside_toc_regressions_test.dart b/test/src/writerside_toc_regressions_test.dart index 173082df..4cdf5994 100644 --- a/test/src/writerside_toc_regressions_test.dart +++ b/test/src/writerside_toc_regressions_test.dart @@ -168,7 +168,12 @@ void main() { Future chooseHome(WidgetTester tester) async { await tester.tap(find.text('Choose a Home Page')); await tester.pumpAndSettle(); - await tester.tap(find.text('guides/install.md')); + await tester.tap( + find.widgetWithText( + BusyMarkPopupMenuItem, + 'guides/install.md', + ), + ); await tester.pumpAndSettle(); } diff --git a/test/src/writerside_variable_source_mapping_test.dart b/test/src/writerside_variable_source_mapping_test.dart new file mode 100644 index 00000000..835c622e --- /dev/null +++ b/test/src/writerside_variable_source_mapping_test.dart @@ -0,0 +1,280 @@ +import 'dart:io'; + +import 'package:busymark/src/editor/wysiwyg/writerside_editing_adapter.dart'; +import 'package:busymark/src/editor/wysiwyg/writerside_inspector_target.dart'; +import 'package:busymark/src/editor/wysiwyg/wysiwyg_document_controller.dart'; +import 'package:busymark/src/editor/wysiwyg/wysiwyg_inline_controller.dart'; +import 'package:busymark/src/markdown/busymark_document.dart'; +import 'package:busymark/src/markdown/markdown_model.dart'; +import 'package:busymark/src/markdown/markdown_parser.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +BusyDocument parse(String source, bool xml) => xml + ? const WritersideEditingAdapter().parseXml( + filePath: 't.topic', + source: source, + )! + : const MarkdownParser() + .parse( + filePath: 't.md', + source: source, + mode: MarkdownMode.writersideMarkdown, + validateLocalReferences: false, + ) + .busyDocument; + +Iterable walk(List blocks) sync* { + for (final b in blocks) { + yield b; + yield* walk(b.children); + } +} + +BusyBlock paragraph(BusyDocument document) => walk(document.blocks).firstWhere( + (b) => + b.kind == BusyBlockKind.paragraph && + busyInlineReferenceRanges( + b.inlines, + ).any((r) => r.kind == BusyInlineKind.writersideVariable), +); + +List variables(BusyBlock block) => + busyInlineReferenceRanges( + block.inlines, + ).where((r) => r.kind == BusyInlineKind.writersideVariable).toList(); + +void verify( + String source, + bool xml, + List names, { + String? alt, + bool br = false, +}) { + final doc = parse(source, xml); + final model = BusyMarkWysiwygDocumentController(document: doc); + try { + final block = paragraph(model.document); + final refs = variables(block); + expect(refs.map((r) => r.attributes['reference']), names); + var position = 0; + var sourceCursor = source.indexOf('

    '); + for (var i = 0; i < refs.length; i++) { + final range = refs[i]; + expect(range.start, position); + expect(range.end, position + names[i].length); + final at = source.indexOf('%${names[i]}%', sourceCursor); + expect(at, greaterThanOrEqualTo(0)); + sourceCursor = at + names[i].length + 2; + final editorTarget = WritersideInspectorTarget.fromSelection( + bufferId: 'b', + revision: 1, + source: source, + document: doc, + blockId: block.id, + inline: range, + ); + expect(editorTarget.sourceLocation, at, reason: 'Editor occurrence $i'); + final sourceTarget = WritersideInspectorTarget.fromSource( + bufferId: 'b', + revision: 1, + source: source, + document: doc, + selection: TextSelection.collapsed(offset: at + 2), + ); + expect(sourceTarget.inline?.kind, BusyInlineKind.writersideVariable); + expect( + sourceTarget.inline?.start, + range.start, + reason: 'Source occurrence $i', + ); + expect(sourceTarget.inline?.end, range.end); + expect(sourceTarget.inline?.referenceIndex, range.referenceIndex); + expect(sourceTarget.sourceLocation, at); + position += names[i].length; + if (i == 0) position += alt?.length ?? (br ? 1 : 0); + } + expect(model.markdown, source); + } finally { + model.dispose(); + } +} + +void main() { + for (final xml in [true, false]) { + for (final image in [true, false]) { + test( + '${xml ? 'XML' : 'embedded XML'} variables after ${image ? 'image' : 'break'} bind every occurrence and mutate only the selected occurrence', + () async { + final body = image + ? '

    %product%Product%product%%product%

    ' + : '

    %product%
    %product%

    '; + final original = xml + ? '$body' + : '# Topic\n\n$body\n'; + final names = List.filled(image ? 3 : 2, 'product'); + verify( + original, + xml, + names, + alt: image ? 'Product' : null, + br: !image, + ); + var source = original; + String change(int ordinal, String value) { + final doc = parse(source, xml); + final model = BusyMarkWysiwygDocumentController(document: doc); + try { + final block = paragraph(model.document); + final refs = variables(block); + var cursor = source.indexOf('

    '); + for (var i = 0; i <= ordinal; i++) { + cursor = source.indexOf( + '%${refs[i].attributes['reference']}%', + cursor, + ); + if (i != ordinal) cursor += refs[i].end - refs[i].start + 2; + } + final target = WritersideInspectorTarget.fromSource( + bufferId: 'b', + revision: 1, + source: source, + document: doc, + selection: TextSelection.collapsed(offset: cursor + 2), + ); + expect( + target.inline?.referenceIndex, + refs[ordinal].referenceIndex, + ); + expect( + model.updateInlineReference( + block.id, + target.inline!, + 'reference', + value, + ), + isTrue, + ); + return model.markdown; + } finally { + model.dispose(); + } + } + + source = change(names.length - 1, 'target_product'); + names[names.length - 1] = 'target_product'; + // In the three-variable example, both preceding references must stay + // unchanged; source selection must never retarget the second one. + final expectedBody = body.replaceRange( + body.lastIndexOf('%product%'), + body.lastIndexOf('%product%') + '%product%'.length, + '%target_product%', + ); + expect( + source.substring(source.indexOf('

    '), source.indexOf('

    ') + 4), + expectedBody, + ); + verify(source, xml, names, alt: image ? 'Product' : null, br: !image); + source = change(0, 'earlier_product_long'); + names[0] = 'earlier_product_long'; + verify(source, xml, names, alt: image ? 'Product' : null, br: !image); + if (image) { + final model = BusyMarkWysiwygDocumentController( + document: parse(source, xml), + ); + try { + final block = paragraph(model.document); + final image = busyInlineReferenceRanges( + block.inlines, + ).singleWhere((r) => r.kind == BusyInlineKind.image); + expect( + model.updateInlineReference( + block.id, + image, + 'alt', + 'Longer product alternative', + ), + isTrue, + ); + source = model.markdown; + } finally { + model.dispose(); + } + verify(source, xml, names, alt: 'Longer product alternative'); + source = change(2, 'final_product'); + names[2] = 'final_product'; + verify(source, xml, names, alt: 'Longer product alternative'); + } else { + source = change(1, 'final_product'); + names[1] = 'final_product'; + verify(source, xml, names, br: true); + } + final dir = await Directory.systemTemp.createTemp( + 'variable-bindings-', + ); + try { + final file = File('${dir.path}/t.${xml ? 'topic' : 'md'}'); + await file.writeAsString(source); + verify( + await file.readAsString(), + xml, + names, + alt: image ? 'Longer product alternative' : null, + br: !image, + ); + } finally { + await dir.delete(recursive: true); + } + }, + ); + } + } + test( + 'XML variable bindings use authored text nodes across entities, CDATA, wrappers and comments', + () { + const source = + '

    & %product%Product
    %product%

    '; + final doc = parse(source, true); + final block = paragraph(doc); + final refs = variables(block); + expect(refs, hasLength(3)); + final offsets = [ + source.indexOf('%product%', source.indexOf('')), + source.indexOf('%product%', source.indexOf(' block.kind == blockCase.kind), + parsed.blocks + .expand( + (block) => + block.attributes['busymark-list-form'] == 'markdown' + ? block.children + : [block], + ) + .where((block) => block.kind == blockCase.kind), hasLength(1), reason: '${blockCase.kind.name} was flattened at paragraph offset $position: $result', ); expect( parsed.blocks + .expand( + (block) => + block.attributes['busymark-list-form'] == 'markdown' + ? block.children + : [block], + ) .singleWhere((block) => block.kind == blockCase.kind) .plainText, blockCase.text, @@ -2096,7 +2109,11 @@ void main() { final pasted = result; expect(pasted, isNot(original)); - expect(RegExp(r'Existing').allMatches(pasted), hasLength(1)); + expect( + RegExp(r'Existing').allMatches(pasted), + hasLength(1), + reason: pasted, + ); final parsed = _parser .parse(filePath: '/math-list.md', source: pasted) .busyDocument; diff --git a/test/src/wysiwyg_selection_test.dart b/test/src/wysiwyg_selection_test.dart index b029e591..20b63423 100644 --- a/test/src/wysiwyg_selection_test.dart +++ b/test/src/wysiwyg_selection_test.dart @@ -196,26 +196,22 @@ void main() { await _key(tester, LogicalKeyboardKey.keyC); expect( clipboard.data.text, - contains('not the final integration result.'), - ); - expect(clipboard.data.text, contains('/mnt/Media/Linguality/gateway')); - expect( - clipboard.data.text, - contains('/mnt/Media/Linguality/learner_client'), + contains('must preserve its final punctuation.'), ); + expect(clipboard.data.text, contains('/opt/example/gateway')); + expect(clipboard.data.text, contains('/opt/example/desktop_client')); expect(clipboard.data.text, contains('A longer body cell')); expect(clipboard.data.text, contains('End!')); + expect(clipboard.data.sourceText, contains('`/opt/example/gateway`')); expect( clipboard.data.sourceText, - contains('`/mnt/Media/Linguality/gateway`'), + contains('`/opt/example/desktop_client`'), ); expect( clipboard.data.sourceText, - contains('`/mnt/Media/Linguality/learner_client`'), - ); - expect( - clipboard.data.sourceText, - contains('**The client must work before corpus population finishes.**'), + contains( + '**The editor preserves selection across paragraph boundaries.**', + ), ); for (final block in tester.widgetList( find.byType(BusyMarkWysiwygBlockField), @@ -441,7 +437,7 @@ void main() { expect(clipboard.data.text, contains(middle.plainText)); expect( clipboard.data.text, - contains('not the final integration result.'), + contains('must preserve its final punctuation.'), ); }); } @@ -778,7 +774,7 @@ void main() { await _key(tester, LogicalKeyboardKey.keyC); expect( clipboard.data.text, - contains('not the final integration result.'), + contains('must preserve its final punctuation.'), ); expect(clipboard.data.text, contains('End!')); } diff --git a/test/src/wysiwyg_session_test.dart b/test/src/wysiwyg_session_test.dart index 490887ca..c58315b3 100644 --- a/test/src/wysiwyg_session_test.dart +++ b/test/src/wysiwyg_session_test.dart @@ -1,8 +1,12 @@ import 'package:busymark/l10n/generated/app_localizations.dart'; import 'package:busymark/src/editor/wysiwyg/wysiwyg_editor.dart'; import 'package:busymark/src/editor/wysiwyg/wysiwyg_session_state.dart'; +import 'package:busymark/src/editor/wysiwyg/writerside_editing_adapter.dart'; +import 'package:busymark/src/editor/wysiwyg/writerside_inspector_target.dart'; +import 'package:busymark/src/editor/wysiwyg/wysiwyg_inline_controller.dart'; import 'package:busymark/src/markdown/busymark_document.dart'; import 'package:busymark/src/markdown/markdown_parser.dart'; +import 'package:busymark/src/markdown/markdown_model.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -22,6 +26,426 @@ class _SessionReportNotifier extends Notifier { void main() { const parser = MarkdownParser(); + testWidgets( + 'pinned non-list Markdown owner refreshes direct edits with authored positions', + (tester) async { + const original = '# Topic\n\n## Chapter A\n\nParagraph B.\n'; + var source = original; + WritersideInspectorTarget? pinned; + var revision = 0; + await tester.pumpWidget( + _app( + BusyMarkWysiwygEditor( + document: parser + .parse( + filePath: 'one.md', + source: original, + mode: MarkdownMode.writersideMarkdown, + ) + .busyDocument, + documentId: 'same', + onSourceChanged: (_, value) { + source = value; + revision++; + }, + onInspectElement: + ( + document, + id, + inline, + explicit, + multiple, { + bool revisionRefresh = false, + }) { + if (explicit) { + pinned = WritersideInspectorTarget.fromSelection( + bufferId: 'same', + revision: revision, + source: source, + document: document, + blockId: id, + inline: inline, + ); + } else if (pinned != null) { + pinned = + pinned!.refreshUnchangedElement( + revision: revision, + source: source, + document: document, + ) ?? + pinned; + } + }, + ), + ), + ); + await tester.pumpAndSettle(); + Finder field(String text) => find.byWidgetPredicate( + (w) => + w is TextField && + w.controller is BusyMarkWysiwygTextController && + w.controller!.text == text, + ); + await tester.tap(field('Chapter A')); + tester + .state(find.byType(BusyMarkWysiwygEditor)) + .inspectCurrentSelection(); + await tester.pumpAndSettle(); + await tester.enterText(field('Paragraph B.'), 'Paragraph B. edited'); + await tester.pumpAndSettle(); + expect(pinned!.block!.plainText, 'Chapter A'); + expect(pinned!.matches('same', revision, source), isTrue); + await tester.enterText(field('Chapter A'), 'Chapter A edited'); + await tester.pumpAndSettle(); + expect(pinned!.matches('same', revision, source), isTrue); + expect(pinned!.block!.plainText, 'Chapter A edited'); + expect(pinned!.block!.attributes['id'], 'chapter-a-edited'); + expect(pinned!.sourceLocation, source.indexOf('## Chapter A edited')); + }, + ); + + for (final withList in [false, true]) { + for (final style in ['Tip', 'Note', 'Warning', 'Quote']) { + testWidgets( + '$style Markdown caret survives workspace source reparse (list=$withList)', + (tester) async { + final original = + '# Topic\n\n${withList ? "- Item\n\n" : ""}$style **example**.\n\nAfter.\n'; + BusyDocument parse(String source) => parser + .parse( + filePath: 'one.md', + source: source, + mode: MarkdownMode.writersideMarkdown, + ) + .busyDocument; + var session = const WysiwygEditorSessionState(); + var input = const WysiwygEditorSessionState(); + var revision = 0; + var source = original; + Widget editor(BusyDocument document) => _app( + BusyMarkWysiwygEditor( + document: document, + documentId: 'same', + contentRevision: revision, + initialSessionState: input, + useExternalUndoHistory: true, + onSessionChanged: (_, value) => session = value, + onSourceChanged: (_, value) => source = value, + ), + ); + Finder field() => find.byWidgetPredicate( + (w) => + w is TextField && + w.controller is BusyMarkWysiwygTextController && + w.controller!.text == '$style example.', + ); + await tester.pumpWidget(editor(parse(original))); + await tester.pumpAndSettle(); + await tester.tap(field()); + tester.widget(field()).controller!.selection = + const TextSelection.collapsed(offset: 4); + await tester.pumpAndSettle(); + final before = session; + await tester.tap(find.byTooltip('Writerside')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Admonitions and quotes')); + await tester.pumpAndSettle(); + await tester.tap(find.text(style)); + await tester.pumpAndSettle(); + final changed = source; + final after = session; + expect(changed, contains('> $style **example**.')); + input = before; + revision++; + await tester.pumpWidget(editor(parse(original))); + await tester.pumpAndSettle(); + expect(tester.widget(field()).focusNode!.hasFocus, isTrue); + input = after; + revision++; + await tester.pumpWidget(editor(parse(changed))); + await tester.pumpAndSettle(); + expect( + tester.widget(field()).focusNode!.hasFocus, + isTrue, + reason: '${after.toJson()}', + ); + expect( + tester.widget(field()).controller!.selection, + const TextSelection.collapsed(offset: 4), + ); + }, + ); + } + } + + for (final xml in [false, true]) { + testWidgets( + '${xml ? "XML" : "Markdown"} caret session survives an exact reparse with new model IDs', + (tester) async { + final source = xml + ? '

    First

    Target

    Third

    ' + : '# Topic\n\nFirst\n\nTarget\n\nThird\n'; + final parsed = xml + ? const WritersideEditingAdapter().parseXml( + filePath: 'one.topic', + source: source, + )! + : parser + .parse( + filePath: 'one.md', + source: source, + mode: MarkdownMode.writersideMarkdown, + ) + .busyDocument; + Iterable walk(List blocks) sync* { + for (final b in blocks) { + yield b; + yield* walk(b.children); + } + } + + final authored = walk( + parsed.blocks, + ).singleWhere((b) => b.plainText == 'Target'); + List remap(List blocks) => [ + for (final b in blocks) + b.plainText == 'Target' + ? b.copyWith(id: 'editing-child') + : b.copyWith(children: remap(b.children)), + ]; + final original = parsed.copyWith(blocks: remap(parsed.blocks)); + var session = const WysiwygEditorSessionState(); + Widget editor(BusyDocument document) => _app( + BusyMarkWysiwygEditor( + document: document, + documentId: 'same-buffer', + initialSessionState: session, + onSessionChanged: (_, value) => session = value, + onSourceChanged: (_, _) => + fail('Caret restoration must not edit source'), + ), + ); + await tester.pumpWidget(editor(original)); + await tester.pumpAndSettle(); + Finder target() => find.byWidgetPredicate( + (w) => w is TextField && w.controller?.text == 'Target', + ); + await tester.tap(target()); + tester.widget(target()).controller!.selection = + const TextSelection.collapsed(offset: 3); + await tester.pumpAndSettle(); + expect(session.activeBlockId, 'editing-child'); + expect(session.sourceBindings['editing-child'], ( + start: authored.sourceSpan!.startOffset, + end: authored.sourceSpan!.endOffset, + kind: 'paragraph', + tag: 'p', + )); + final restored = WysiwygEditorSessionState.fromJson(session.toJson()); + expect(restored.sourceSignature, session.sourceSignature); + expect(restored.sourceBindings, session.sourceBindings); + await tester.pumpWidget(const SizedBox.shrink()); + session = restored; + await tester.pumpWidget(editor(parsed)); + await tester.pumpAndSettle(); + final field = tester.widget(target()); + expect(field.focusNode!.hasFocus, isTrue); + expect( + field.controller!.selection, + const TextSelection.collapsed(offset: 3), + ); + expect(session.activeBlockId, authored.id); + }, + ); + } + + for (final xml in [false, true]) { + testWidgets( + '${xml ? "XML" : "Markdown"} revision refresh publishes restored anchor and enum state at the unchanged caret', + (tester) async { + String source(int state) { + final attrs = state == 0 + ? '' + : ' id="explicit"${state == 2 ? ' collapsible="true"' : ''}'; + return xml + ? '

    Body

    ' + : '# Topic\n\n## Heading${attrs.isEmpty ? '' : ' {${attrs.trim()}}'}\n\nBody\n'; + } + + BusyDocument parse(String text) => xml + ? const WritersideEditingAdapter().parseXml( + filePath: 'one.topic', + source: text, + )! + : parser + .parse( + filePath: 'one.md', + source: text, + mode: MarkdownMode.writersideMarkdown, + ) + .busyDocument; + BusyBlock heading(BusyDocument d) => xml + ? d.blocks.first.children.firstWhere( + (b) => b.kind == BusyBlockKind.heading, + ) + : d.blocks.firstWhere((b) => b.plainText == 'Heading'); + var revision = 0; + var document = parse(source(0)); + final id = heading(document).id; + var session = WysiwygEditorSessionState( + activeBlockId: id, + anchorBlockId: id, + extentBlockId: id, + anchorOffset: 4, + extentOffset: 4, + ); + final snapshots = []; + final refreshes = []; + var opened = 0; + Widget editor() => _app( + BusyMarkWysiwygEditor( + document: document, + documentId: 'same-buffer', + contentRevision: revision, + initialSessionState: session, + onSessionChanged: (_, restored) => session = restored, + onSourceChanged: (_, _) => fail('Inspection must not edit source'), + onPropertiesRequested: () => opened++, + onInspectElement: + ( + model, + blockId, + inline, + explicit, + multiple, { + bool revisionRefresh = false, + }) { + expect(session.activeBlockId, id); + expect(session.anchorOffset, 4); + expect(session.extentOffset, 4); + snapshots.add( + WritersideInspectorTarget.fromSelection( + bufferId: 'same-buffer', + revision: revision, + source: document.source!, + document: model, + blockId: blockId, + inline: inline, + multiple: multiple, + ), + ); + refreshes.add(revisionRefresh); + }, + ), + ); + await tester.pumpWidget(editor()); + await tester.pumpAndSettle(); + expect(snapshots.last.matches('same-buffer', 0, source(0)), isTrue); + // Add, Undo, Redo, reset, Undo, Redo, enum change, Undo, Redo. + for (final state in [1, 0, 1, 0, 1, 0, 2, 1, 2]) { + document = parse(source(state)); + revision++; + final before = snapshots.length; + await tester.pumpWidget(editor()); + await tester.pumpAndSettle(); + expect(snapshots.length, greaterThan(before)); + final target = snapshots.last; + expect( + target.matches('same-buffer', revision, source(state)), + isTrue, + ); + expect(target.block?.plainText, 'Heading'); + expect( + target.block?.attributes['id'], + state == 0 ? (xml ? null : 'heading') : 'explicit', + ); + expect(target.block?.attributes['generatedId'] == 'true', state == 0); + expect( + target.block?.attributes['collapsible'], + state == 2 ? 'true' : null, + ); + expect(refreshes.last, isTrue); + final field = tester.widget( + find.byWidgetPredicate( + (w) => + w is TextField && + w.controller is BusyMarkWysiwygTextController && + w.controller!.text == 'Heading', + ), + ); + expect( + field.controller!.selection, + const TextSelection.collapsed(offset: 4), + ); + } + // A new authoritative revision also matters when the source is equal. + revision++; + final before = snapshots.length; + await tester.pumpWidget(editor()); + await tester.pumpAndSettle(); + expect(snapshots.length, greaterThan(before)); + expect(snapshots.last.revision, revision); + expect(refreshes.last, isTrue); + final stable = snapshots.length; + await tester.pumpWidget(editor()); + await tester.pumpAndSettle(); + expect(snapshots.length, stable); + expect(opened, 0); + }, + ); + } + testWidgets('obsolete restore frames cannot publish an intermediate model', ( + tester, + ) async { + var revision = 0; + var document = parser + .parse(filePath: 'one.md', source: 'Heading\n') + .busyDocument; + final id = document.blocks.first.id; + const selection = TextSelection.collapsed(offset: 3); + final sources = []; + Widget editor() => _app( + BusyMarkWysiwygEditor( + document: document, + documentId: 'same', + contentRevision: revision, + initialSessionState: WysiwygEditorSessionState( + activeBlockId: id, + anchorBlockId: id, + extentBlockId: id, + anchorOffset: 3, + extentOffset: 3, + ), + onSourceChanged: (_, _) {}, + onInspectElement: (model, _, _, _, _, {bool revisionRefresh = false}) => + sources.add(model.source!), + ), + ); + await tester.pumpWidget(editor()); + await tester.pumpAndSettle(); + sources.clear(); + document = parser + .parse(filePath: 'one.md', source: 'Intermediate\n') + .busyDocument; + revision++; + await tester.pumpWidget(editor()); + document = parser + .parse(filePath: 'one.md', source: 'Latest\n') + .busyDocument; + revision++; + await tester.pumpWidget(editor()); + await tester.pumpAndSettle(); + expect(sources, isNotEmpty); + expect(sources.every((s) => s == 'Latest\n'), isTrue); + expect( + tester + .widget(find.byType(TextField).first) + .controller! + .selection, + selection, + ); + }); + testWidgets('restores WYSIWYG selection from document session state', ( tester, ) async { diff --git a/test/support/writerside_authoring_native_smoke.dart b/test/support/writerside_authoring_native_smoke.dart index fd40175c..72a5fbeb 100644 --- a/test/support/writerside_authoring_native_smoke.dart +++ b/test/support/writerside_authoring_native_smoke.dart @@ -3,14 +3,18 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'dart:ui' as ui; +import 'package:busymark/l10n/generated/app_localizations.dart'; import 'package:busymark/src/app/app_settings.dart'; import 'package:busymark/src/app/busymark_app.dart'; import 'package:busymark/src/app/busymark_design.dart'; import 'package:busymark/src/app/startup_path.dart'; import 'package:busymark/src/app/system_accent.dart'; import 'package:busymark/src/editor/wysiwyg/wysiwyg_editor.dart'; +import 'package:busymark/src/editor/wysiwyg/wysiwyg_block_widgets.dart'; import 'package:busymark/src/editor/wysiwyg/writerside_properties.dart'; import 'package:busymark/src/editor/wysiwyg/wysiwyg_inline_controller.dart'; +import 'package:busymark/src/editor/source/source_editor.dart'; +import 'package:busymark/src/markdown/busymark_document.dart'; import 'package:busymark/src/local_history/local_history_store.dart'; import 'package:busymark/src/local_history/local_history_controller.dart'; import 'package:busymark/src/platform/linux_header_bar_service.dart'; @@ -26,6 +30,19 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:path/path.dart' as p; import 'package:window_manager/window_manager.dart'; +const _invalidationCases = [ + (true, false, false), + (false, false, false), + (true, true, false), + (true, false, true), + (false, false, true), +]; +String _invalidationStem(bool xml, bool malformed, bool draft) => + 'invalidation-${xml ? 'xml' : 'markdown'}${malformed ? '-invalid' : ''}${draft ? '-draft' : ''}'; +String _invalidationSource(bool xml, bool malformed, bool draft) => xml + ? '\n

    Original owner

    \n

    Other owner

    \n
    \n' + : '# Invalidation\n\nOriginal owner\n\nOther owner\n'; + Future main(List arguments) async { WidgetsFlutterBinding.ensureInitialized(); if (arguments.length != 2) throw ArgumentError('FIXTURE OUTPUT_DIRECTORY'); @@ -44,32 +61,166 @@ Future main(List arguments) async { await item.copy(destination); } } - await File(p.join(root.path, 'topics/authoring.topic')).writeAsString( - '

    Text

    Nested content

    print(1);
    ', - ); - await File(p.join(root.path, 'topics/authoring.md')).writeAsString( - '# Authoring Markdown\n\nText\n\n## Section\n\nNested content\n', - ); + if (Platform.environment['BUSYMARK_PROPERTY_CONTRACT_ONLY'] != '1') { + for (final name in ['contract-xml.topic', 'contract-markdown.md']) { + await File( + p.join( + 'test/fixtures/writerside/property_contract_conformance/topics', + name, + ), + ).copy(p.join(root.path, 'topics', name)); + } + await File(p.join(root.path, 'topics/authoring.topic')).writeAsString( + '

    Text

    Nested content

    print(1);
    ', + ); + await File(p.join(root.path, 'topics/authoring-markdown.md')).writeAsString( + '# Authoring Markdown\n\nText\n\n## Section\n\nNested content\n', + ); + await File( + p.join(root.path, 'README.md'), + ).writeAsString('# Ordinary Markdown\n\nPlain text\n'); + await File(p.join(root.path, 'topics/inspector.topic')).writeAsString( + '

    Caret A

    Nested body

    Definition.

    ', + ); + await File(p.join(root.path, 'topics/inspector-markdown.md')).writeAsString( + '# Inspector {id="inspector-markdown"}\n\nCaret A\n\n## Block B\n\n### Nested\n\nNested body\n\n```text\n```\n{id="file" src="inspector-sample.txt"}\n', + ); + await File( + p.join(root.path, 'topics/inspector-sample.txt'), + ).writeAsString('first\nsecond\nthird\n'); + await File( + p.join(root.path, 'topics/inspector-corrections.md'), + ).writeAsString( + '# Inspector Corrections\n\nUse `{id="literal-code"}`\n\nUse [**link**](authoring.topic){id="owned-link"}\n\n' + 'See [`{id="linked-literal"}`](authoring.topic){id="code-reference"}\n\n' + 'Show ![{id="image-literal"}](logo.png){id="alt-reference" width="24"}\n\n' + '- First **item**\n- Second item\n\n+ [x] Checked task\n+ [ ] Unchecked task\n\n> A **note**.\n{style="note"}\n\n' + 'Numbered override.\n\n3. Numbered parent\n 4. Nested first\n 5. Nested second\n4. Numbered sibling\n\n' + '{start="10" id="numbered-list"}\n\nNumbered markers.\n\n' + '3. Marker parent\n 4. Marker nested first\n 5. Marker nested second\n4. Marker sibling\n', + ); + await Directory(p.join(root.path, 'images')).create(); + await File( + p.join(fixture.parent.path, 'property_conformance/images/logo.png'), + ).copy(p.join(root.path, 'images/logo.png')); + await File( + p.join(fixture.parent.path, 'property_conformance/images/logo_dark.png'), + ).copy(p.join(root.path, 'images/logo_dark.png')); + final configuration = File(p.join(root.path, 'writerside.cfg')); + await configuration.writeAsString( + (await configuration.readAsString()).replaceFirst( + '', + '', + ), + ); + await File( + p.join(root.path, 'topics/inspector-mappings.topic'), + ).writeAsString( + '

    Press Ctrl+S now.

    Open Guide now.

    Logo
    ', + ); + for (final xml in [true, false]) { + await File( + p.join( + root.path, + 'topics/inline-references-${xml ? 'xml.topic' : 'markdown.md'}', + ), + ).writeAsString( + xml + ? '

    Before Read guide and Other.

    Vars %product% and %product%.

    Keys Save and Save.

    Empty .

    ' + : '# Inline references Markdown\n\nBefore [Read **guide**][ref] and [Other][ref].\n\nVars %product% and %product%.\n\nKeys Save and Save.\n\nEmpty .\n\n[ref]: inspector.topic "Kept title"\n', + ); + } + for (final xml in [true, false]) { + const body = + '

    %product%Product%product%%product%

    %product%
    %product%

    '; + await File( + p.join( + root.path, + 'topics/variable-media-${xml ? 'xml.topic' : 'markdown.md'}', + ), + ).writeAsString( + xml + ? '$body' + : '# Variable media Markdown\n\n$body\n', + ); + } + final variables = File(p.join(root.path, 'v.list')); + await variables.writeAsString( + (await variables.readAsString()).replaceFirst( + '', + '', + ), + ); + final tree = File(p.join(root.path, 'conformance.tree')); + await tree.writeAsString( + (await tree.readAsString()).replaceFirst( + '', + '', + ), + ); + } + const presentationSource = + '# Inspector presentation\n\n## 2. Diagnosed cause and source map\n\nBody for presentation checks.\n\n## Long generated anchor documentation documentation documentation documentation documentation documentation documentation documentation documentation documentation documentation documentation\n\nLong anchor body.\n'; await File( - p.join(root.path, 'README.md'), - ).writeAsString('# Ordinary Markdown\n\nPlain text\n'); - final tree = File(p.join(root.path, 'conformance.tree')); - await tree.writeAsString( - (await tree.readAsString()).replaceFirst( + p.join(root.path, 'topics/inspector-presentation.md'), + ).writeAsString(presentationSource); + final presentationTree = File(p.join(root.path, 'conformance.tree')); + await presentationTree.writeAsString( + (await presentationTree.readAsString()).replaceFirst( '', - '', + '', ), ); + final defaultsFixture = Directory( + p.join(fixture.parent.path, 'property_defaults_conformance'), + ); + final defaultsRoot = Directory('${root.path}-defaults'); + await for (final item in defaultsFixture.list(recursive: true)) { + final destination = p.join( + defaultsRoot.path, + p.relative(item.path, from: defaultsFixture.path), + ); + if (item is Directory) await Directory(destination).create(recursive: true); + if (item is File) { + await File(destination).parent.create(recursive: true); + await item.copy(destination); + } + } final output = await Directory( p.absolute(arguments[1]), ).create(recursive: true); + final contextualFixture = Directory( + p.absolute('test/fixtures/writerside/list_capitalization_conformance'), + ); + final contextualRoot = Directory('${root.path}-contextual'); + await for (final item in contextualFixture.list(recursive: true)) { + final destination = p.join( + contextualRoot.path, + p.relative(item.path, from: contextualFixture.path), + ); + if (item is Directory) await Directory(destination).create(recursive: true); + if (item is File) { + await File(destination).parent.create(recursive: true); + await item.copy(destination); + } + } await windowManager.ensureInitialized(); + for (final (xml, malformed, draft) in _invalidationCases) { + await File( + p.join( + contextualRoot.path, + 'topics/${_invalidationStem(xml, malformed, draft)}.${xml ? 'topic' : 'md'}', + ), + ).writeAsString(_invalidationSource(xml, malformed, draft)); + } await LinuxHeaderBarService.instance.initialize(); final settings = AppSettings.defaults().copyWith( localeTag: 'en', themeModePreference: BusyMarkThemeModePreference.light, documentViewMode: DocumentViewModePreference.editor, autoSave: false, + elementPropertiesVisible: true, + elementPropertiesWidth: 390, reopenPreviousWorkspaceOnStartup: false, confirmCloseWithUnsavedChanges: false, ); @@ -126,12 +277,40 @@ class _HarnessState extends ConsumerState<_Harness> { final _checks = []; var _pointer = 0; var _nativeMenuOpen = false; + int? _accessibilitySessionExit; + + Future _startAccessibilitySession() async { + if (Platform.environment['BUSYMARK_NATIVE_PROBE'] != '1') return; + await _pause(); + final process = await Process.start('/usr/bin/python3', [ + p.absolute('tools/linux_native_menu_probe.py'), + '$pid', + 'hold', + ]); + process.stderr.transform(utf8.decoder).listen(stderr.write); + unawaited( + process.exitCode.then((code) { + _accessibilitySessionExit = code; + }), + ); + final ready = await process.stdout + .transform(utf8.decoder) + .transform(const LineSplitter()) + .first + .timeout(const Duration(seconds: 30)); + _check(ready == 'ready', 'Native accessibility session connects'); + } Future _native( String command, [ String? argument, String? secondArgument, ]) async { + if (_accessibilitySessionExit != null) { + throw StateError( + 'Native accessibility session exited: $_accessibilitySessionExit', + ); + } final result = await Process.run('/usr/bin/python3', [ p.absolute('tools/linux_native_menu_probe.py'), '$pid', @@ -155,6 +334,51 @@ class _HarnessState extends ConsumerState<_Harness> { } } + Future _hoverProperties(Element element) async { + final label = AppLocalizations.of( + _elements((w) => w is BusyMarkWysiwygEditor).single, + ).wsElementProperties; + await Scrollable.ensureVisible( + element, + alignment: 0.25, + duration: const Duration(milliseconds: 200), + ); + await _pause(); + final box = element.findRenderObject()! as RenderBox; + final origin = await windowManager.getPosition(); + final point = box.localToGlobal(box.size.center(Offset.zero)) + origin; + await _native('move-pointer', '${point.dx.round()}', '${point.dy.round()}'); + await _until( + () => + _elements( + (w) => w is BusyMarkHeaderIconButton && w.tooltip == label, + ).length == + 1, + 'One contextual Element Properties icon appears for ${element.widget.runtimeType}', + ); + final icon = _elements( + (w) => w is BusyMarkHeaderIconButton && w.tooltip == label, + ).single; + final margin = icon.findRenderObject()! as RenderBox; + final target = + margin.localToGlobal(margin.size.center(Offset.zero)) + origin; + await _native( + 'move-pointer', + '${target.dx.round()}', + '${target.dy.round()}', + ); + await _pause(); + _check( + _elements( + (w) => w is BusyMarkHeaderIconButton && w.tooltip == label, + ).length == + 1, + 'Contextual Properties remains reachable across the margin', + ); + await _native('click-pointer'); + await _pause(); + } + @override void initState() { super.initState(); @@ -211,6 +435,7 @@ class _HarnessState extends ConsumerState<_Harness> { ); await Scrollable.ensureVisible( element, + alignment: 0.25, duration: const Duration(milliseconds: 200), ).timeout(const Duration(seconds: 5)); await _pause(); @@ -287,6 +512,9 @@ class _HarnessState extends ConsumerState<_Harness> { } Future _capture(String name) async { + if (await windowManager.getPosition() != Offset.zero) { + await windowManager.setPosition(Offset.zero); + } await _pause(); if (Platform.environment['BUSYMARK_NATIVE_PROBE'] == '1') { await _native('capture', p.join(widget.output.path, '$name.png')); @@ -315,6 +543,115 @@ class _HarnessState extends ConsumerState<_Harness> { w.controller is BusyMarkWysiwygTextController && w.controller!.text == text, ).first; + + String _inspectorLookupContext(String expected, int count) { + final state = ref.read(workspaceControllerProvider); + final targets = _elements((w) => w is BusyMarkWritersideProperties) + .map((e) { + final panel = e.widget as BusyMarkWritersideProperties; + final target = panel.target; + return '${panel.document.filePath}: ' + '${panel.topicSelected ? 'topic' : target?.attributes['element'] ?? target?.kind.name ?? 'no element'} ' + '(block=${target?.id}, anchor=${target?.attributes['id']}, ' + 'inline=${panel.inlineTarget?.kind.name})'; + }) + .join('; '); + return 'Expected exactly one inspector control "$expected", found $count; ' + 'document=${state.activeBuffer?.filePath}, ' + 'buffer=${state.activeBuffer?.id}; ' + 'targets=${targets.isEmpty ? 'no inspector' : targets}'; + } + + List _inspectorControls( + bool Function(Widget) matches, + ) => _elements(matches) + .where( + (e) => + e.findAncestorWidgetOfExactType() != + null, + ) + .toList(); + + Element _singleInspectorControl( + String expected, + bool Function(Widget) matches, + ) { + final controls = _inspectorControls(matches); + if (controls.length != 1) { + throw StateError(_inspectorLookupContext(expected, controls.length)); + } + return controls.single; + } + + BusyMarkGroupedTextEntry _inspectorProperty(String label) => + _singleInspectorControl( + label, + (w) => w is BusyMarkGroupedTextEntry && w.label == label, + ).widget + as BusyMarkGroupedTextEntry; + + Future _expandInspectorSection( + String title, + String expectedControl, + bool Function(Widget) hasControl, + ) async { + if (_inspectorControls(hasControl).isNotEmpty) { + _singleInspectorControl(expectedControl, hasControl); + return; + } + await _tap( + _singleInspectorControl( + '$title section', + (w) => + w is TextButton && + w.key == ValueKey('writerside-disclosure-$title'), + ), + ); + await _until( + () => _inspectorControls(hasControl).isNotEmpty, + _inspectorLookupContext(expectedControl, 0), + ); + _singleInspectorControl(expectedControl, hasControl); + } + + Future _requestProperties() async { + await _native('chord', 'ctrl+shift+p'); + await _until( + () => _elements( + (w) => w is BusyMarkGroupedTextEntry && w.label == 'Type a command', + ).isNotEmpty, + 'Element Properties command palette opens', + ); + await _type( + _elements( + (w) => w is TextField && w.decoration?.labelText == 'Type a command', + ).single, + 'Element Properties', + ); + await _native('chord', 'Return'); + await _pause(); + } + + Future _closeInspector() async { + await _tap( + _elements( + (w) => w is BusyMarkHeaderIconButton && w.tooltip == 'Close', + ).single, + ); + await _pause(); + } + + Future _topicProperties() async { + await _tap( + _singleInspectorControl( + 'Element', + (w) => w is BusyMarkComboRow && w.title == 'Element', + ), + ); + _nativeMenuOpen = true; + await _tapLabel('Topic Properties'); + } + Future _choose(String key, List labels) async { await _tap(_elements((w) => w.key == ValueKey(key)).single); _nativeMenuOpen = true; @@ -341,17 +678,19 @@ class _HarnessState extends ConsumerState<_Harness> { // Exercise the actual Linux window at accessibility text scaling. GTK's // DPI setting does not change Flutter's text scaler on this embedding. Element? media; - _elements((w) => w is BusyMarkWysiwygEditor).single.visitAncestorElements(( - e, - ) { + _elements( + (w) => w is BusyMarkWritersideProperties, + ).single.visitAncestorElements((e) { if (e.widget is MediaQuery) { media = e; - return false; } return true; }); final element = media!; final original = element.widget as MediaQuery; + // Scale the outer view data. BusyMark derives another MediaQuery for GTK + // animation settings, which can rebuild during a capture; overriding that + // inner copy would be lost instead of propagating through the real app. // Inherited-widget updates must notify their dependants during a build // scope. Updating the element directly from this async probe asserts in // the debug application and prevents the scaling acceptance check. @@ -363,144 +702,3053 @@ class _HarnessState extends ConsumerState<_Harness> { ), ); }); - await _pause(); + await WidgetsBinding.instance.endOfFrame; _check( MediaQuery.textScalerOf( - _elements((w) => w is BusyMarkWysiwygEditor).single, + _elements((w) => w is BusyMarkWritersideProperties).single, ).scale(14) == 21, 'Linux text scaling applied', ); await _capture(name); + _check( + MediaQuery.textScalerOf( + _elements((w) => w is BusyMarkWritersideProperties).single, + ).scale(14) == + 21, + 'Linux text scaling remains applied through capture', + ); element.owner!.buildScope(element, () => element.update(original)); await _pause(); } - Future _run() async { - Object? failure; - try { + Future _runInlineReferences( + Future Function() openInspector, + ) async { + final controller = ref.read(workspaceControllerProvider.notifier); + BusyMarkWritersideProperties inspector() => + _elements((w) => w is BusyMarkWritersideProperties).single.widget + as BusyMarkWritersideProperties; + Future editorCaret(String text, int offset) async { + await _native('chord', 'ctrl+shift+1'); + await _pause(); + await _tap(_field(text)); + (_field(text).widget as TextField).controller!.selection = + TextSelection.collapsed(offset: offset); + await _pause(); + await _requestProperties(); + } + + Future sourceCaret(int offset) async { + ((_elements((w) => w is BusyMarkSourceEditor).single as StatefulElement) + .state + as BusyMarkSourceEditorState) + .scrollToOffset(offset); + await _pause(); + await _requestProperties(); + } + + Future property(String label, String value) async { + final entry = + _elements( + (w) => w is BusyMarkGroupedTextEntry && w.label == label, + ).single.widget + as BusyMarkGroupedTextEntry; + await _type( + _elements( + (w) => w is TextField && w.controller == entry.controller, + ).single, + value, + ); + await _key(PhysicalKeyboardKey.enter, LogicalKeyboardKey.enter); + } + + Future showSource(int offset, String label) async { + await _tapLabel('Show in Source'); await _until( - () => - ref.read(workspaceControllerProvider).workspace?.rootPath == - widget.root.path, - 'Fixture workspace loaded', + () => _elements((w) => w is BusyMarkSourceEditor).isNotEmpty, + '$label switches to Source', ); - final controller = ref.read(workspaceControllerProvider.notifier); - final settings = ref.read(appSettingsControllerProvider.notifier); - for (final suffix in ['topic', 'md']) { + await _pause(); + _check( + ref + .read(workspaceControllerProvider) + .activeBuffer! + .editorState + .selection + .extentOffset == + offset, + '$label navigates to exact markup occurrence', + ); + _check( + FocusManager.instance.primaryFocus?.context + ?.findAncestorWidgetOfExactType() != + null, + '$label restores source focus', + ); + } + + Future reopen() async { + await _native('chord', 'ctrl+s'); + await _until( + () => !ref.read(workspaceControllerProvider).activeBuffer!.isDirty, + 'Inline references saved', + ); + final buffer = ref.read(workspaceControllerProvider).activeBuffer!; + _check( + await File(buffer.filePath!).readAsString() == buffer.text, + 'Inline references persisted exactly', + ); + _check( + await controller.closeDocumentBuffer(buffer.id), + 'Inline reference buffer closes', + ); + await controller.openActiveFile(buffer.filePath!); + await _pause(); + } + + if (Platform.environment['BUSYMARK_VARIABLE_MEDIA_ONLY'] != '1') { + for (final xml in [true, false]) { + final suffix = xml ? 'xml' : 'markdown'; await controller.openActiveFile( - p.join(widget.root.path, 'topics/authoring.$suffix'), - ); - await _until( - () => _elements((w) => w is BusyMarkWysiwygEditor).isNotEmpty, - 'Editor available for $suffix', - ); - await _pause(); - final body = _field('Text'); - stdout.writeln( - 'Native text scale: ${MediaQuery.textScalerOf(body).scale(14) / 14}', - ); - await _tap(body); - (body.widget as TextField).controller!.selection = const TextSelection( - baseOffset: 0, - extentOffset: 4, - ); - await _pause(); - final beforeMenu = _source; - final selected = (body.widget as TextField).controller!.selection; - await _tap( - _elements( - (w) => w.key == const ValueKey('wysiwyg-writerside-insert'), - ).single, - ); - _nativeMenuOpen = true; - await _native('key', 'Down'); - await _capture('authoring-$suffix-insert-menu'); - await _native('key', 'Escape'); - _nativeMenuOpen = false; - await _pause(); - stdout.writeln( - 'GTK dismissal: source unchanged=${_source == beforeMenu}; ' - 'selection before=$selected after=${(body.widget as TextField).controller!.selection}; ' - 'editing focus=${(body.widget as TextField).focusNode!.hasFocus}; ' - 'primary focus=${FocusManager.instance.primaryFocus?.debugLabel}', - ); - _check( - _source == beforeMenu && - (body.widget as TextField).controller!.selection == selected && - (body.widget as TextField).focusNode!.hasFocus, - '$suffix GTK keyboard dismissal preserves source, selection and focus', + p.join( + widget.root.path, + 'topics/inline-references-${xml ? 'xml.topic' : 'markdown.md'}', + ), ); - await _choose('wysiwyg-writerside-semantic', ['UI Control']); + await editorCaret('Before Read guide and Other.', 9); + if (!ref.read(appSettingsControllerProvider).elementPropertiesVisible) { + await openInspector(); + } + final original = _source; + for (final offset in [9, 15]) { + await editorCaret('Before Read guide and Other.', offset); + _check( + inspector().inlineTarget?.kind == BusyInlineKind.link && + inspector().inlineTarget!.end - + inspector().inlineTarget!.start == + 'Read guide'.length, + '$suffix mixed link selects complete owner at caret $offset', + ); + } + await _capture('inline-$suffix-mixed-link-editor'); + await property('Link target (href)', 'inspector.topic#nested'); _check( - _source.contains('Text'), - '$suffix semantic formatting authors control', - ); - _keyData( - PhysicalKeyboardKey.controlLeft, - LogicalKeyboardKey.controlLeft, - ui.KeyEventType.down, - ); - await _key(PhysicalKeyboardKey.keyZ, LogicalKeyboardKey.keyZ); - _keyData( - PhysicalKeyboardKey.controlLeft, - LogicalKeyboardKey.controlLeft, - ui.KeyEventType.up, + _source.contains(xml ? 'Read guide' : 'Read **guide**') && + _source.contains( + xml ? 'Other' : '[Other][ref]', + ), + '$suffix link edit retains mixed formatting and other occurrence', ); + final changed = _source; _check( - !_source.contains(''), - '$suffix semantic action undo is one buffer operation', - ); - _keyData( - PhysicalKeyboardKey.controlLeft, - LogicalKeyboardKey.controlLeft, - ui.KeyEventType.down, - ); - _keyData( - PhysicalKeyboardKey.shiftLeft, - LogicalKeyboardKey.shiftLeft, - ui.KeyEventType.down, + changed != original && + changed.contains( + xml + ? 'href="inspector.topic#nested"' + : '(inspector.topic#nested "Kept title")', + ), + '$suffix link destination changes', ); - await _key(PhysicalKeyboardKey.keyZ, LogicalKeyboardKey.keyZ); - _keyData( - PhysicalKeyboardKey.shiftLeft, - LogicalKeyboardKey.shiftLeft, - ui.KeyEventType.up, + await showSource( + _source.indexOf(xml ? 'Text'), - '$suffix semantic redo preserves type', + await _capture('inline-$suffix-mixed-link-source'); + for (final shortcut in [false, true]) { + var text = shortcut + ? 'Keys Save and Save.' + : 'Vars product and product.'; + final kind = shortcut + ? BusyInlineKind.writersideShortcut + : BusyInlineKind.writersideVariable; + final token = shortcut ? ' verifyMedia(String stage) async { + for (final image in [true, false]) { + final names = image ? imageNames : breakNames; + final text = image + ? '${names.first}$alt${names.skip(1).join()}' + : names.join('\n'); + final paragraph = _source.indexOf( + image ? '

    0 ? i + 1 : i; + await editorCaret(text, position + 1); + _check( + inspector().inlineTarget?.referenceIndex == referenceIndex && + inspector().inlineTarget?.start == position && + inspector().inlineTarget?.kind == + BusyInlineKind.writersideVariable, + '$suffix $stage ${image ? "image" : "break"} Editor variable $i has exact target', + ); + await showSource( + at, + '$suffix $stage ${image ? "image" : "break"} variable $i', + ); + await sourceCaret(at + 2); + _check( + inspector().inlineTarget?.referenceIndex == referenceIndex && + inspector().inlineTarget?.start == position, + '$suffix $stage ${image ? "image" : "break"} Source variable $i has exact target', + ); + position += names[i].length; + if (i == 0) position += image ? alt.length : 1; + } + } + } + + await editorCaret('productProductproductproduct', 1); + if (!ref.read(appSettingsControllerProvider).elementPropertiesVisible) { + await openInspector(); + } + await verifyMedia('initial'); + final original = _source; + var third = _source.indexOf( + '%product%', + _source.indexOf('%product%') + 1, + ); + third = _source.indexOf('%product%', third + 1); + await sourceCaret(third + 2); + await property('Variable Reference', 'product_long'); + final editedThird = _source; + third = _source.indexOf('%product_long%', _source.indexOf('%product%%product_long%

    '), + '$suffix editing third variable preserves first and second', + ); + await _native('chord', 'ctrl+z'); + await _pause(); + _check( + _source == original, + '$suffix third variable undo restores exact source', + ); + await _native('chord', 'ctrl+shift+z'); + await _pause(); + _check( + _source == editedThird, + '$suffix third variable redo restores exact edit', + ); + imageNames[2] = 'product_long'; + await verifyMedia('third edited'); + await reopen(); + await verifyMedia('third reopened'); + await sourceCaret(_source.indexOf('%product%') + 2); + await property('Variable Reference', 'product_long'); + imageNames[0] = 'product_long'; + await sourceCaret(_source.indexOf('%product%
    %product_long%

    ', + ), + '$suffix second break variable changes only its occurrence', + ); + await _native('chord', 'ctrl+z'); + await _pause(); + _check( + _source == beforeBreak, + '$suffix break variable undo restores exact source', + ); + await _native('chord', 'ctrl+shift+z'); + await _pause(); + _check( + _source == afterBreak, + '$suffix break variable redo restores exact edit', + ); + breakNames[1] = 'product_long'; + await sourceCaret( + _source.indexOf( + '%product%', + _source.indexOf('

    _runChapterPresentation() async { + const heading = '2. Diagnosed cause and source map'; + final controller = ref.read(workspaceControllerProvider.notifier); + final settings = ref.read(appSettingsControllerProvider.notifier); + final path = p.join(widget.root.path, 'topics/inspector-presentation.md'); + await controller.openActiveFile(path); + await _native('chord', 'ctrl+shift+1'); + await _until( + () => _elements( + (w) => + w is TextField && + w.controller is BusyMarkWysiwygTextController && + w.controller!.text == heading, + ).isNotEmpty, + 'Presentation heading opens in Editor', + ); + final original = _source; + Future closeInspector() async { + await _tap( + _elements( + (w) => w is BusyMarkHeaderIconButton && w.tooltip == 'Close', + ).single, + ); + await _until( + () => !ref.read(appSettingsControllerProvider).elementPropertiesVisible, + 'Inspector Close hides the pane', + ); + } + + if (ref.read(appSettingsControllerProvider).elementPropertiesVisible) { + await closeInspector(); + } + await _tap(_field(heading)); + await _hoverProperties(_field(heading)); + await _until( + () => _elements( + (w) => + w is BusyMarkWritersideProperties && w.target?.plainText == heading, + ).isNotEmpty, + 'Heading click then Element Properties identifies the chapter', + ); + BusyMarkWritersideProperties inspector() => + _elements((w) => w is BusyMarkWritersideProperties).single.widget + as BusyMarkWritersideProperties; + BusyMarkComboRow combo(String title) => + _singleInspectorControl( + title, + (w) => w is BusyMarkComboRow && w.title == title, + ).widget + as BusyMarkComboRow; + _check( + combo('Element').labelFor(combo('Element').selected) == 'Chapter', + 'Exact heading click → Properties shows Element — Chapter', + ); + _check( + _elements( + (w) => + w is Text && + w.data == 'Select a single element to inspect its properties.', + ).isEmpty, + 'Valid chapter never shows the empty-selection instruction', + ); + _check( + _elements( + (w) => w.key == const ValueKey('writerside-properties-identity'), + ).isEmpty, + 'Editable chapter has no repeated identity strip', + ); + _check( + _inspectorProperty('Heading text').controller!.text == heading && + _inspectorProperty('Element anchor (id)').controller!.text.isEmpty, + 'Heading text and absent anchor override have distinct values', + ); + const generated = 'Generated: 2-diagnosed-cause-and-source-map'; + _check( + _elements((w) => w is SelectableText && w.data == generated).length == 1, + 'Generated anchor is readable and selectable in its property unit', + ); + _check( + combo('Collapsible (collapsible)').subtitle == 'Default: Disabled' && + combo('Collapsible (collapsible)').selected == '', + 'Unspecified chapter collapse shows compact built-in default', + ); + _check( + _elements( + (w) => + w is RichText && + w.text.toPlainText() == + 'Generated anchor; setting a value authors an override.', + ).isEmpty, + 'Generated-anchor explanation is not permanent form prose', + ); + await _capture('presentation-heading-properties-light'); + final anchor = _inspectorProperty('Element anchor (id)'); + await _tap( + _elements( + (w) => w is TextField && w.controller == anchor.controller, + ).single, + ); + await _native('chord', 'F1'); + await _pause(); + _check( + _elements( + (w) => + w is RichText && + w.text.toPlainText() == + 'Generated anchor; setting a value authors an override.', + ).isNotEmpty, + 'F1 opens generated-anchor contextual help', + ); + await _capture('presentation-anchor-help'); + await _tap(_field(heading)); + await _native('chord', 'ctrl+s'); + await _until( + () => !ref.read(workspaceControllerProvider).activeBuffer!.isDirty, + 'Presentation neutral Save finishes', + ); + _check( + _source == original && await File(path).readAsString() == original, + 'Focus, blur, help and Save do not author a generated ID', + ); + await _type( + _field('Body for presentation checks.'), + 'Body for presentation checks. edited.', + ); + _check( + inspector().target?.plainText == heading && + inspector().document.source == _source && + _inspectorProperty('Heading text').controller!.text == heading, + 'Non-list Markdown body edit refreshes the same pinned chapter owner', + ); + await _native('chord', 'ctrl+z'); + await _pause(); + _check( + _source == original && + _inspectorProperty('Heading text').controller!.text == heading, + 'Non-list body Undo retains the pinned chapter immediately', + ); + await _type(_field(heading), '$heading edited'); + _check( + inspector().target?.plainText == '$heading edited' && + inspector().document.source == _source && + _inspectorProperty('Heading text').controller!.text == + '$heading edited' && + _inspectorProperty('Element anchor (id)').controller!.text.isEmpty && + _elements( + (w) => w is SelectableText && w.data == '$generated-edited', + ).length == + 1 && + !_source.contains('{id='), + 'Direct non-list heading edit refreshes its generated anchor without authoring an override', + ); + await _native('chord', 'ctrl+z'); + await _pause(); + _check( + _source == original && + _inspectorProperty('Heading text').controller!.text == heading && + _elements((w) => w is SelectableText && w.data == generated).length == + 1, + 'Direct heading Undo immediately restores the pinned title and generated anchor', + ); + await closeInspector(); + await _hoverProperties(_field(heading)); + _check( + _source == original && + _inspectorProperty('Element anchor (id)').controller!.text.isEmpty, + 'Closing and reopening properties keeps generated source unchanged', + ); + Future anchorEdit(String value) async { + await _tap(_field(heading)); + await _until( + () => _elements( + (w) => + w is BusyMarkWritersideProperties && + w.target?.plainText == heading, + ).isNotEmpty, + 'Heading is selected for the anchor transaction', + ); + final field = _inspectorProperty('Element anchor (id)'); + await _type( + _elements( + (w) => w is TextField && w.controller == field.controller, + ).single, + value, + ); + await _key(PhysicalKeyboardKey.enter, LogicalKeyboardKey.enter); + } + + Future restoredInspector( + String action, { + required bool generatedAnchor, + bool collapsible = false, + }) async { + // Check the restored view before another caret, click or navigation event. + await _until( + () => + _elements( + (w) => + w is BusyMarkWritersideProperties && + w.document.source == _source && + w.target?.plainText == heading, + ).length == + 1, + '$action republishes the selected chapter at the restored revision', + ); + _check( + _inspectorProperty('Element anchor (id)').controller!.text == + (generatedAnchor ? '' : 'diagnosed-cause-map') && + _elements( + (w) => w is SelectableText && w.data == generated, + ).length == + (generatedAnchor ? 1 : 0) && + combo('Collapsible (collapsible)').selected == + (collapsible ? 'true' : '') && + combo('Collapsible (collapsible)').subtitle == + (collapsible ? null : 'Default: Disabled') && + (collapsible + ? combo('Published initial state (default-state)').selected == + '' && + combo( + 'Published initial state (default-state)', + ).subtitle == + 'Default: Collapsed' + : _inspectorControls( + (w) => + w is BusyMarkComboRow && + w.title == 'Published initial state (default-state)', + ).isEmpty), + '$action keeps the chapter, anchor and collapse values current without caret movement', + ); + } + + await anchorEdit('diagnosed-cause-map'); + await _until( + () => inspector().target?.attributes['id'] == 'diagnosed-cause-map', + 'Anchor override is authored', + ); + final overrideSource = _source; + _check( + _elements( + (w) => w.key == const ValueKey('writerside-generated-anchor'), + ).isEmpty, + 'Authored override replaces the generated summary', + ); + await _tap(_field(heading)); + await _native('chord', 'ctrl+z'); + await _until( + () => _source == original, + 'Native undo restores generated anchor', + ); + await restoredInspector('Anchor Undo', generatedAnchor: true); + await _native('chord', 'ctrl+shift+z'); + await _until( + () => _source == overrideSource, + 'Native redo restores explicit anchor', + ); + await restoredInspector('Anchor Redo', generatedAnchor: false); + await anchorEdit(''); + await _until( + () => inspector().target?.attributes['generatedId'] == 'true', + 'Removing override restores generated anchor', + ); + _check( + _source == original, + 'Removing the override restores the original authored source', + ); + await _tap(_field(heading)); + await _native('chord', 'ctrl+z'); + await _until( + () => _source == overrideSource, + 'Undo of reset restores the override', + ); + await restoredInspector('Anchor reset Undo', generatedAnchor: false); + await _native('chord', 'ctrl+shift+z'); + await _until( + () => _source == original, + 'Redo of reset restores generated behavior', + ); + await restoredInspector('Anchor reset Redo', generatedAnchor: true); + await anchorEdit('diagnosed-cause-map'); + final collapseElement = _singleInspectorControl( + 'Collapsible (collapsible)', + (w) => + w is BusyMarkComboRow && + w.title == 'Collapsible (collapsible)', + ); + final unchangedCaret = + (_field(heading).widget as TextField).controller!.selection; + await _tap(collapseElement); + _nativeMenuOpen = true; + await _tapLabel('Enabled'); + _check( + combo('Collapsible (collapsible)').subtitle == null, + 'Explicit collapse does not repeat an effective-value sentence', + ); + final collapsedSource = _source; + _check( + (_field(heading).widget as TextField).controller!.selection == + unchangedCaret, + 'Collapse property change leaves heading text and caret unchanged', + ); + await _native('chord', 'ctrl+z'); + await _until( + () => _source == overrideSource, + 'Collapse Undo restores the absent attribute', + ); + await restoredInspector('Collapse Undo', generatedAnchor: false); + _check( + (_field(heading).widget as TextField).controller!.selection == + unchangedCaret, + 'Collapse Undo leaves heading text and caret unchanged', + ); + await _native('chord', 'ctrl+shift+z'); + await _until( + () => _source == collapsedSource, + 'Collapse Redo restores the authored attribute', + ); + await restoredInspector( + 'Collapse Redo', + generatedAnchor: false, + collapsible: true, + ); + _check( + (_field(heading).widget as TextField).controller!.selection == + unchangedCaret, + 'Collapse Redo leaves heading text and caret unchanged', + ); + await _native('chord', 'ctrl+s'); + await _until( + () => !ref.read(workspaceControllerProvider).activeBuffer!.isDirty, + 'Presentation override save finishes', + ); + final saved = _source; + await _native('chord', 'ctrl+w'); + await _until( + () => !ref + .read(workspaceControllerProvider) + .documentBuffers + .any((buffer) => buffer.filePath == path), + 'Presentation document closes before reopening', + ); + await controller.openActiveFile(path); + await _native('chord', 'ctrl+shift+1'); + await _until( + () => _elements( + (w) => + w is TextField && + w.controller is BusyMarkWysiwygTextController && + w.controller!.text == heading, + ).isNotEmpty, + 'Presentation document reopens in Editor', + ); + await _tap(_field(heading)); + await _hoverProperties(_field(heading)); + _check( + _source == saved && + _inspectorProperty('Element anchor (id)').controller!.text == + 'diagnosed-cause-map', + 'Anchor override and explicit collapse survive save close reopen', + ); + await _capture('presentation-heading-properties-reopened'); + final longHeading = inspector().document.blocks + .firstWhere( + (b) => + b.kind == BusyBlockKind.heading && + b.plainText.startsWith('Long generated anchor'), + ) + .plainText; + await _tap(_field(longHeading)); + await _pause(); + _check( + inspector().target?.plainText == heading, + 'Ordinary heading selection keeps the explicitly inspected owner', + ); + await _hoverProperties(_field(longHeading)); + _check( + _inspectorProperty('Element anchor (id)').controller!.text.isEmpty && + inspector().target?.attributes['generatedId'] == 'true', + 'Target change does not transfer an authored anchor draft', + ); + final longAnchor = inspector().target!.attributes['id']!; + for (final width in [390.0, 260.0]) { + await settings.setElementPropertiesWidth(width); + await settings.setThemeModePreference( + width == 260 + ? BusyMarkThemeModePreference.dark + : BusyMarkThemeModePreference.light, + ); + await _capture('presentation-long-anchor-${width.toInt()}'); + await _captureScaled('presentation-long-anchor-${width.toInt()}-scaled'); + _check( + _elements( + (w) => + w is SelectableText && + w.data == 'Generated: $longAnchor' && + w.maxLines == null, + ).length == + 1, + 'Long generated anchor remains complete at width ${width.toInt()} and increased scaling', + ); + } + await settings.setElementPropertiesWidth(390); + await settings.setThemeModePreference(BusyMarkThemeModePreference.light); + // The first chapter now has published collapse enabled; use the visible + // second chapter's body to begin the native multiple-selection gesture. + await _tap(_field('Long anchor body.')); + await _native('chord', 'ctrl+a'); + await _native('chord', 'ctrl+a'); + await _pause(); + await _requestProperties(); + _check( + _elements( + (w) => + w is Text && + w.data == 'Select a single element to inspect its properties.', + ).isNotEmpty && + _elements((w) => w is BusyMarkWritersideProperties).isEmpty, + 'Multiple Editor selection shows feedback instead of arbitrary editable properties', + ); + await _capture('presentation-multiple-selection'); + await _native('chord', 'Escape'); + final emptyPath = p.join(widget.root.path, 'topics/inspector-empty.md'); + await File(emptyPath).writeAsString(''); + await controller.openActiveFile(emptyPath); + await _native('chord', 'ctrl+shift+2'); + await _pause(); + _check( + _elements( + (w) => + w is Text && + w.data == 'Select a single element to inspect its properties.', + ).isNotEmpty && + _elements((w) => w is BusyMarkWritersideProperties).isEmpty, + 'Empty Source document shows selection feedback without arbitrary editable fields', + ); + await _capture('presentation-empty-selection'); + await _native('chord', 'ctrl+w'); + await _until( + () => !ref + .read(workspaceControllerProvider) + .documentBuffers + .any((buffer) => buffer.filePath == emptyPath), + 'Empty document closes before fixture cleanup', + ); + await File(emptyPath).delete(); + await controller.openActiveFile(path); + await _native('chord', 'ctrl+shift+1'); + await _pause(); + if (ref.read(appSettingsControllerProvider).elementPropertiesVisible) { + await closeInspector(); + } + _check( + await File(path).readAsString() == saved, + 'Presentation inspection, scaling and selection changes preserve saved source', + ); + } + + Future _runPropertyContract( + Future Function() openInspector, + ) async { + final controller = ref.read(workspaceControllerProvider.notifier); + final settings = ref.read(appSettingsControllerProvider.notifier); + BusyMarkWritersideProperties inspector() => + _elements((w) => w is BusyMarkWritersideProperties).single.widget + as BusyMarkWritersideProperties; + List fields() => _elements( + (w) => w is BusyMarkGroupedTextEntry, + ).map((e) => (e.widget as BusyMarkGroupedTextEntry).label).toList(); + Future sourceCaret(int offset) async { + await _native('chord', 'ctrl+shift+2'); + await _until( + () => _elements((w) => w is BusyMarkSourceEditor).isNotEmpty, + 'Contract Source mode is ready', + ); + await _pause(); + ((_elements((w) => w is BusyMarkSourceEditor).single as StatefulElement) + .state + as BusyMarkSourceEditorState) + .scrollToOffset(offset); + await _pause(); + await _requestProperties(); + } + + Future section(String title, {String? field}) async { + if (field != null && fields().contains(field)) return; + await _tap( + _elements( + (w) => + w is TextButton && + w.key == ValueKey('writerside-disclosure-$title'), + ).single, + ); + } + + Future property(String label, String value) async { + final entry = _inspectorProperty(label); + await _type( + _elements( + (w) => w is TextField && w.controller == entry.controller, + ).single, + value, + ); + await _key(PhysicalKeyboardKey.enter, LogicalKeyboardKey.enter); + } + + Future combo(String title, String label) async { + await _tap( + _elements( + (w) => w is BusyMarkComboRow && w.title == title, + ).single, + ); + _nativeMenuOpen = true; + await _tapLabel(label); + } + + Future reopen() async { + await _native('chord', 'ctrl+s'); + await _until( + () => !ref.read(workspaceControllerProvider).activeBuffer!.isDirty, + 'Contract source saved', + ); + final buffer = ref.read(workspaceControllerProvider).activeBuffer!; + _check( + await File(buffer.filePath!).readAsString() == buffer.text, + 'Contract source persisted exactly', + ); + _check( + await controller.closeDocumentBuffer(buffer.id), + 'Contract buffer closes', + ); + await controller.openActiveFile(buffer.filePath!); + await _pause(); + } + + for (final xml in [true, false]) { + final stem = xml ? 'contract-xml' : 'contract-markdown'; + final path = p.join( + widget.root.path, + 'topics/$stem.${xml ? 'topic' : 'md'}', + ); + await controller.openActiveFile(path); + await sourceCaret(_source.indexOf(xml ? ' + w is BusyMarkHeaderIconButton && w.tooltip == 'Rename Topic File', + ), + ); + await _pause(); + await _tapLabel('Cancel'); + _check( + _source == initial && await File(path).exists(), + '$stem identity routes to the existing rename workflow and cancellation preserves source', + ); + + await property( + xml ? 'Default title (title)' : 'Heading text', + xml ? 'Edited XML topic' : 'Edited Markdown topic', + ); + _check( + _source.contains( + xml ? 'id="contract-xml"' : 'id="authored-topic-anchor"', + ), + '$stem title preserves identity and authored anchor', + ); + _check( + _source.contains( + xml ? 'topic title' : 'custom-setting: untouched', + ), + '$stem title preserves conditional markup or front matter', + ); + await _tapLabel('Show in Source'); + await _pause(); + final changedTitle = _source; + await _native('chord', 'ctrl+z'); + await _pause(); + _check(_source == initial, '$stem title undo is one step'); + await _native('chord', 'ctrl+shift+z'); + await _pause(); + _check(_source == changedTitle, '$stem title redo preserves source'); + await sourceCaret(_source.indexOf(xml ? ' + w is BusyMarkGroupedTextEntry && + w.label == 'Navigation elements (for)', + ).single.widget + as BusyMarkGroupedTextEntry; + await _type( + _elements( + (w) => w is TextField && w.controller == entry.controller, + ).single, + 'chapter,procedure,tab,def', + ); + final depthEntry = + _elements( + (w) => + w is BusyMarkGroupedTextEntry && + w.label == 'Navigation depth (depth)', + ).single.widget + as BusyMarkGroupedTextEntry; + await _type( + _elements( + (w) => w is TextField && w.controller == depthEntry.controller, + ).single, + 'invalid', + ); + final beforeInvalid = _source; + await openInspector(); + _check( + ref.read(appSettingsControllerProvider).elementPropertiesVisible && + _source == beforeInvalid, + '$stem palette close cannot discard an invalid navigation draft', + ); + await _capture('$stem-navigation-invalid'); + await property('Navigation depth (depth)', '2'); + final navigationSource = _source; + _check( + navigationSource.contains('for="chapter,procedure,tab,def"') && + navigationSource.contains('depth="2"'), + '$stem compound navigation publishes both values', + ); + await _capture('$stem-navigation-custom'); + await _tapLabel('Show in Source'); + await _pause(); + await _native('chord', 'ctrl+z'); + await _pause(); + _check(_source == beforeInvalid, '$stem compound navigation is one undo'); + await _native('chord', 'ctrl+shift+z'); + await _pause(); + _check( + _source == navigationSource, + '$stem compound navigation redo restores both fields', + ); + await sourceCaret(_source.indexOf(xml ? 'chapter title' : 'title="Published Markdown override"', + ), + '$stem default chapter title preserves distinct overrides', + ); + await property( + 'Element anchor (id)', + xml ? 'edited-xml-chapter' : 'edited-markdown-chapter', + ); + await _native('chord', 'ctrl+shift+1'); + await _pause(); + await _tap( + _field(xml ? 'Edited XML chapter' : 'Edited Markdown chapter'), + ); + await _pause(); + _check( + !inspector().topicSelected, + '$stem Editor selects the complete chapter owner', + ); + _check( + fields().contains(xml ? 'Default title (title)' : 'Heading text'), + '$stem inspector remains usable in Editor', + ); + await section('Advanced', field: 'Help instances (instance)'); + if (!xml) { + await property('Title override (title)', 'Edited Markdown override'); + } + await property('Help instances (instance)', 'conformance'); + _check( + _source.contains('instance="conformance"'), + '$stem conditional chapter setting is authored', + ); + await property('Help instances (instance)', ''); + await sourceCaret( + _source.indexOf(xml ? '

    '!other$i').join(','); + await property('Help instances (instance)', longInstances); + await _capture('$stem-long-instances'); + _check( + inspector().target!.attributes['instance'] == longInstances, + '$stem long conditional values are retained', + ); + await property('Help instances (instance)', '!other,conformance'); + _check( + _source.contains('instance="!other,conformance"'), + '$stem paragraph permits legal instance exclusions and lists', + ); + await property('Help instances (instance)', 'conformance'); + await property('Filter (filter)', 'private'); + _check( + _source.contains('filter="private"'), + '$stem filter edits criteria independently', + ); + await property('Filter (filter)', 'public'); + await _capture('$stem-paragraph-advanced'); + await _native('chord', 'Tab'); + await _pause(); + _check( + FocusManager.instance.primaryFocus != null, + '$stem keyboard traversal retains focus', + ); + await _native('chord', 'ctrl+shift+4'); + await _pause(); + _check( + _elements((w) => w is BusyMarkSourceEditor).isNotEmpty, + '$stem Split source is present', + ); + await _native('chord', 'ctrl+shift+3'); + await _pause(); + _check(inspector().readOnly, '$stem Reading inspector is read-only'); + final readOnlySource = _source; + await _capture('$stem-reading'); + await _native('chord', 'ctrl+shift+2'); + await _pause(); + _check( + _source == readOnlySource, + '$stem changing views does not edit source', + ); + await settings.setThemeModePreference(BusyMarkThemeModePreference.dark); + await windowManager.setSize(const Size(860, 760)); + await _pause(); + await _capture('$stem-narrow-dark'); + _check( + _elements((w) => w is BusyMarkWritersideProperties).length == 1, + '$stem narrow window keeps one inspector', + ); + await windowManager.setSize(const Size(1440, 1000)); + await settings.setThemeModePreference(BusyMarkThemeModePreference.light); + await _pause(); + final saved = _source; + await reopen(); + _check( + _source == saved, + '$stem save close reopen preserves exact source', + ); + await sourceCaret(_source.indexOf(xml ? ' _source.contains('Published $stem instance'), + '$stem existing title workflow publishes the instance title', + ); + } else { + await _until( + () => + !_elements((w) => w is Text && w.data == 'Edit Title').isNotEmpty, + '$stem title workflow closes', + ); + } + _check( + _source.contains( + xml ? 'title="Edited XML topic"' : '# Edited Markdown topic', + ), + '$stem instance title does not replace the default owner', + ); + _check( + xml + ? _source.contains('topic title') + : _source.contains('custom-setting: untouched'), + '$stem instance edit preserves surrounding authored content', + ); + _check( + xml ? _source != titleSource : _source == titleSource, + '$stem instance scope is independently authored or protected according to builder support', + ); + _check( + (await File( + p.join(widget.root.path, 'conformance.tree'), + ).readAsString()).contains('toc-title="Published $stem TOC"'), + '$stem TOC override edits the tree entry', + ); + } + } + + Future _runContextualListProperties( + Future Function() paletteProperties, + ) async { + final controller = ref.read(workspaceControllerProvider.notifier); + final settings = ref.read(appSettingsControllerProvider.notifier); + final root = '${widget.root.path}-contextual'; + await settings.setLocaleTag('en'); + await settings.setThemeModePreference(BusyMarkThemeModePreference.light); + await settings.setEditorToolbarDirection(EditorToolbarDirection.horizontal); + await windowManager.setSize(const Size(1400, 960)); + await controller.openPath(root); + Future open(String name) async { + final path = p.join(root, 'topics', name); + await controller.openActiveFile(path); + await _native('chord', 'ctrl+shift+1'); + await _until( + () => + ref.read(workspaceControllerProvider).activeBuffer?.filePath == + path && + _elements( + (w) => w is BusyMarkWysiwygEditor && w.document.filePath == path, + ).isNotEmpty && + _elements( + (w) => w is TextField && w.controller?.text == 'Caret A', + ).isNotEmpty, + 'Contextual $name opens in Editor', + ); + } + + BusyMarkWritersideProperties pane() => + _elements((w) => w is BusyMarkWritersideProperties).single.widget + as BusyMarkWritersideProperties; + BusyMarkComboRow combo(String label) => + _singleInspectorControl( + label, + (w) => w is BusyMarkComboRow && w.title == label, + ).widget + as BusyMarkComboRow; + Future choose(String label, String value) async { + await _tap( + _singleInspectorControl( + label, + (w) => w is BusyMarkComboRow && w.title == label, + ), + ); + _nativeMenuOpen = true; + await _tapLabel(value); + } + + Future property(String label, String value) async { + final entry = _inspectorProperty(label); + await _type( + _elements( + (w) => w is TextField && w.controller == entry.controller, + ).single, + value, + ); + await _key(PhysicalKeyboardKey.enter, LogicalKeyboardKey.enter); + } + + Future advanced() async { + if (_elements( + (w) => + w is BusyMarkComboRow && w.title == 'Capitalization (caps)', + ).isEmpty) { + await _tapLabel('Advanced'); + } + await _until( + () => + _elements( + (w) => + w is BusyMarkComboRow && + w.title == 'Capitalization (caps)', + ).length == + 1, + 'Capitalization is accessible in Advanced', + ); + } + + await open('lists.topic'); + _check( + _elements( + (w) => w.key == const ValueKey('wysiwyg-writerside-properties'), + ).isEmpty, + 'Formatting toolbar has no duplicate Properties button', + ); + for (final text in [ + 'a journey through HTTP and APIs', + 'Chapter body.', + 'Code B', + 'Image B', + 'Parent item', + 'Folded chapter', + ]) { + await _tap(_field('Caret A')); + final caret = + (_field('Caret A').widget as TextField).controller!.selection; + final before = _source; + final undo = ref + .read(workspaceControllerProvider) + .activeBuffer! + .editorState + .undoState + .undo + .length; + final field = _elements( + (w) => + w is BusyMarkWysiwygBlockField && + (w.block.plainText == text || + w.block.attributes['id'] == 'image' && text == 'Image B'), + ).first; + await _hoverProperties(field); + _check( + _source == before && + ref + .read(workspaceControllerProvider) + .activeBuffer! + .editorState + .undoState + .undo + .length == + undo && + (_field('Caret A').widget as TextField).controller!.selection == + caret, + 'Hover $text inspects independently of caret A without source or history changes', + ); + await _captureInspectorLayouts( + 'form-${text.replaceAll(RegExp(r'[^a-zA-Z]+'), '-')}', + ); + if (text == 'Parent item') { + _check( + pane().target!.attributes['element'] == 'li', + 'Hovering XML item content inspects li', + ); + } + } + await open('native-lists.md'); + await _tap(_field('Caret A')); + final caret = (_field('Caret A').widget as TextField).controller!.selection; + await _hoverProperties(_field('Nested second')); + _check( + pane().target!.kind == BusyBlockKind.orderedListItem, + 'Hovering native nested item inspects the item', + ); + final parent = pane().path + .where((b) => b.attributes['element'] == 'list') + .last; + final select = combo('Element'); + final label = select.labelFor(parent.id); + _check( + label.contains('Nested first') && label.contains('Level 2'), + 'Nested list menu identifies content and nesting', + ); + await _capture('contextual-list-ancestry'); + await choose('Element', label); + _check( + pane().target!.attributes['element'] == 'list' && + (_field('Caret A').widget as TextField).controller!.selection == + caret, + 'Selecting parent list preserves the editor caret', + ); + final original = _source; + await property('Element anchor (id)', 'reviewed-nested'); + await property('Starting number (start)', '12'); + _check( + _source.contains(' {id="reviewed-nested" start="12"}') && + _source.contains('3) Parent **caption**'), + 'Nested list attributes own an indented native declaration', + ); + await _tapLabel('Show in Source'); + _check( + ref + .read(workspaceControllerProvider) + .activeBuffer! + .editorState + .selection + .baseOffset == + _source.indexOf('4) Nested first'), + 'Show in Source navigates to the selected nested list boundary', + ); + await _native('chord', 'ctrl+shift+1'); + await _until( + () => _elements((w) => w is BusyMarkWysiwygEditor).isNotEmpty, + 'Nested list returns to Editor', + ); + await _hoverProperties(_field('Parent caption guide')); + final outer = pane().path + .where((b) => b.attributes['element'] == 'list') + .first; + await choose('Element', combo('Element').labelFor(outer.id)); + await property('Element anchor (id)', 'reviewed-list'); + await property('Starting number (start)', '10'); + await property('Columns (columns)', '2'); + final beforeSorting = _source; + await choose('Sorting (sorted)', 'Descending'); + _check( + _source.indexOf('Parent **caption**') < _source.indexOf('1) Sibling'), + 'Published sorting does not reorder source items', + ); + await _native('chord', 'ctrl+z'); + await _until( + () => _source == beforeSorting, + 'Sorting Undo restores source', + ); + await _until( + () => + _elements( + (w) => + w is BusyMarkWritersideProperties && + w.target?.attributes['id'] == 'reviewed-list' && + w.document.source == _source, + ).length == + 1, + 'List inspector refreshes immediately after Undo', + ); + _check( + combo('Sorting (sorted)').selected == '' && + _inspectorProperty('Starting number (start)').controller!.text == + '10', + 'Undo keeps whole-list values current without caret movement', + ); + await _native('chord', 'ctrl+shift+z'); + await _until( + () => _source.contains('sorted="desc"'), + 'Sorting Redo restores declaration', + ); + await _until( + () => + _elements( + (w) => + w is BusyMarkWritersideProperties && + w.target?.attributes['id'] == 'reviewed-list' && + w.document.source == _source, + ).length == + 1, + 'List inspector refreshes immediately after Redo', + ); + _check( + combo('Sorting (sorted)').selected == 'desc', + 'Redo keeps the selected whole list without another pointer action', + ); + _check( + _source.contains('4) Nested first') && + _source.contains('start="12"') && + original.contains('1) Sibling'), + 'Parent changes preserve nested numbering and source markers', + ); + await _capture('contextual-list-container'); + await _captureInspectorLayouts('form-list'); + await _tap(_field('a journey through HTTP and APIs')); + await _hoverProperties(_field('a journey through HTTP and APIs')); + await advanced(); + _check( + combo('Capitalization (caps)').selected == '' && + combo( + 'Capitalization (caps)', + ).subtitle!.contains('Default: As written'), + 'Absent capitalization is resolved as written', + ); + _check( + combo( + 'Capitalization (caps)', + ).subtitle!.contains('Module capitalization is not published.') && + combo( + 'Capitalization (caps)', + ).tooltip!.contains('ignores module '), + 'Verified module capitalization limitation stays concise and retains full help', + ); + final beforeCaps = _source; + await choose('Capitalization (caps)', 'Uppercase'); + _check( + _source.contains('## a journey through HTTP and APIs {caps="upper"}'), + 'Capitalization edits its attribute, not heading text', + ); + await _native('chord', 'ctrl+z'); + await _until( + () => _source == beforeCaps, + 'Capitalization Undo restores source', + ); + await _until( + () => + _elements( + (w) => + w is BusyMarkWritersideProperties && + w.target?.plainText == 'a journey through HTTP and APIs' && + w.document.source == _source, + ).length == + 1, + 'Capitalization inspector refreshes immediately after Undo', + ); + _check( + combo('Capitalization (caps)').selected == '' && + combo( + 'Capitalization (caps)', + ).subtitle!.contains('Default: As written'), + 'Capitalization Undo restores the verified default without moving caret', + ); + await _native('chord', 'ctrl+shift+z'); + await _until( + () => _source.contains('caps="upper"'), + 'Capitalization Redo restores source', + ); + await _until( + () => + _elements( + (w) => + w is BusyMarkWritersideProperties && + w.target?.plainText == 'a journey through HTTP and APIs' && + w.document.source == _source, + ).length == + 1, + 'Capitalization inspector refreshes immediately after Redo', + ); + _check( + combo('Capitalization (caps)').selected == 'upper', + 'Capitalization Redo restores the explicit value immediately', + ); + await _native('chord', 'ctrl+s'); + await _until( + () => !ref.read(workspaceControllerProvider).activeBuffer!.isDirty, + 'Contextual document saves', + ); + final saved = _source; + await _native('chord', 'ctrl+w'); + await _until( + () => !ref + .read(workspaceControllerProvider) + .documentBuffers + .any((b) => b.filePath == p.join(root, 'topics', 'native-lists.md')), + 'Contextual saved document closes before reopening', + ); + await open('native-lists.md'); + _check( + _source == saved, + 'Native list and capitalization source survives save close reopen', + ); + await _tap(_field('Parent caption guide')); + if (ref.read(appSettingsControllerProvider).elementPropertiesVisible) { + await _closeInspector(); + } + await paletteProperties(); + _check( + pane().target!.plainText == 'Parent caption guide', + 'Keyboard command still inspects the current selection', + ); + await settings.setThemeModePreference(BusyMarkThemeModePreference.dark); + await _closeInspector(); + await windowManager.setSize(const Size(800, 900)); + await _pause(); + await _hoverProperties(_field('Nested first')); + await _capture('contextual-native-list-dark-narrow'); + await _captureScaled('contextual-native-list-dark-scaled'); + await settings.setThemeModePreference(BusyMarkThemeModePreference.light); + await windowManager.setSize(const Size(1400, 960)); + await _capture('contextual-native-list-light'); + await _closeInspector(); + _check( + !ref.read(appSettingsControllerProvider).elementPropertiesVisible, + 'Contextual journey closes its inspector through the guarded close control', + ); + await settings.setLocaleTag('ar'); + await _pause(); + await _hoverProperties(_field('مرحبا بالمستندات التقنية')); + _check( + pane().target!.plainText == 'مرحبا بالمستندات التقنية' && + _source == saved, + 'RTL margin inspection captures its paragraph without authoring source', + ); + await _capture('contextual-rtl-margin'); + await settings.setLocaleTag('en'); + await _pause(); + await _closeInspector(); + await controller.openPath(widget.root.path); + } + + Future _captureInspectorLayouts(String name) async { + final settings = ref.read(appSettingsControllerProvider.notifier); + final before = _source; + final panel = + _elements((w) => w is BusyMarkWritersideProperties).single.widget + as BusyMarkWritersideProperties; + final target = panel.target?.id; + final reference = panel.inlineTarget?.referenceIndex; + final section = _inspectorControls( + (w) => + w is TextButton && + w.key == const ValueKey('writerside-disclosure-Advanced'), + ).singleOrNull; + var wasExpanded = false; + if (section != null) { + section.visitAncestorElements((e) { + if (e.widget case Semantics( + properties: final properties, + ) when properties.expanded != null) { + wasExpanded = properties.expanded!; + return false; + } + return true; + }); + if (!wasExpanded) await _tap(section); + } + for (final width in [390.0, 260.0]) { + await settings.setElementPropertiesWidth(width); + for (final dark in [false, true]) { + await settings.setThemeModePreference( + dark + ? BusyMarkThemeModePreference.dark + : BusyMarkThemeModePreference.light, + ); + await _pause(); + final suffix = '$name-${width.toInt()}-${dark ? "dark" : "light"}'; + final origin = await windowManager.getPosition(); + await _native( + 'move-pointer', + '${(origin.dx + 160).round()}', + '${(origin.dy + 24).round()}', + ); + await Scrollable.ensureVisible( + _singleInspectorControl( + 'Element', + (w) => w is BusyMarkComboRow && w.title == 'Element', + ), + alignment: 0, + ); + await _pause(); + await _capture('$suffix-top'); + await _captureScaled('$suffix-top-scaled'); + await Scrollable.ensureVisible( + _singleInspectorControl( + 'Show in Source', + (w) => w is BusyMarkDialogButton && w.label == 'Show in Source', + ), + alignment: 1, + ); + await _pause(); + await _capture('$suffix-bottom'); + await _captureScaled('$suffix-bottom-scaled'); + } + } + await settings.setLocaleTag('ar'); + await _pause(); + await _capture('$name-rtl'); + await _captureScaled('$name-rtl-scaled'); + await settings.setLocaleTag('en'); + await settings.setElementPropertiesWidth(390); + await settings.setThemeModePreference(BusyMarkThemeModePreference.light); + await _pause(); + if (section != null && !wasExpanded) { + await _tap( + _singleInspectorControl( + 'Advanced', + (w) => + w is TextButton && + w.key == const ValueKey('writerside-disclosure-Advanced'), + ), + ); + } + final current = + _elements((w) => w is BusyMarkWritersideProperties).single.widget + as BusyMarkWritersideProperties; + _check( + _source == before && + current.target?.id == target && + current.inlineTarget?.referenceIndex == reference, + '$name whole-panel width/theme/RTL/scaling inspection preserves source and target', + ); + } + + Future _runSemanticMenuCommands(bool xml) async { + BusyMarkWritersideProperties pane() => + _elements((w) => w is BusyMarkWritersideProperties).single.widget + as BusyMarkWritersideProperties; + _check( + pane().document.isXmlTopic == xml, + '${xml ? "XML" : "Markdown"} semantic menu runs in the intended production document', + ); + for (final semantic in const [ + ('UI Control', 'control', 'Open & Files'), + ('File / Path', 'path', 'docs/example.txt'), + ('UI Path', 'ui-path', 'File | Open'), + ('Keyboard Shortcuts', 'shortcut', 'Ctrl+M'), + ]) { + await _tap(_field('Semantic insertion.')); + (_field('Semantic insertion.').widget as TextField) + .controller! + .selection = const TextSelection( + baseOffset: 0, + extentOffset: 8, + ); + await _pause(); + final before = _source; + final pinned = pane().target!.id; + await _choose('wysiwyg-writerside', ['Semantic text', semantic.$1]); + _check( + _source.contains('<${semantic.$2}>Semantic') && + pane().target!.id == pinned, + '${semantic.$1} wraps selected text from the consolidated native menu without retargeting inspection', + ); + await _native('chord', 'ctrl+z'); + await _pause(); + _check( + _source == before && pane().target!.id == pinned, + '${semantic.$1} selected wrapping has one Undo', + ); + await _tap(_field('Semantic insertion.')); + (_field('Semantic insertion.').widget as TextField) + .controller! + .selection = const TextSelection.collapsed( + offset: 19, + ); + await _pause(); + await _choose('wysiwyg-writerside', ['Semantic text', semantic.$1]); + _check( + _source == before, + '${semantic.$1} collapsed insertion is source-neutral while input is open', + ); + await _tapLabel('Cancel'); + await _pause(); + _check( + _source == before && + (_field('Semantic insertion.').widget as TextField) + .controller! + .selection + .extentOffset == + 19, + '${semantic.$1} cancellation preserves source and original caret', + ); + await _choose('wysiwyg-writerside', ['Semantic text', semantic.$1]); + final input = + _elements( + (w) => + w is BusyMarkGroupedTextEntry && + w.key == const ValueKey('wysiwyg-semantic-text'), + ).single.widget + as BusyMarkGroupedTextEntry; + await _type( + _elements( + (w) => w is TextField && w.decoration?.labelText == input.label, + ).single, + semantic.$3, + ); + await _native('chord', 'Return'); + await _pause(); + final changed = _source; + _check( + changed.contains('<${semantic.$2}>') && + changed != before && + pane().target!.id == pinned, + '${semantic.$1} collapsed input authors a semantic element without retargeting inspection', + ); + await _native('chord', 'ctrl+z'); + await _pause(); + _check(_source == before, '${semantic.$1} input insertion has one Undo'); + await _native('chord', 'ctrl+shift+z'); + await _pause(); + _check( + _source == changed, + '${semantic.$1} input Redo restores escaped markup', + ); + // Retain each semantic element for saved-document and published checks, + // while keeping the next command's input paragraph independently editable. + await _native('chord', 'End'); + await _native('chord', 'Return'); + await _native('type', 'Semantic insertion.'); + await _pause(); + } + for (final style in ['Tip', 'Note', 'Warning', 'Quote']) { + await _tap(_field('$style example.')); + final before = _source; + final target = pane().target!.id; + await _choose('wysiwyg-writerside', ['Admonitions and quotes', style]); + final changed = _source; + _check( + changed != before && + changed.contains(xml ? '<${style.toLowerCase()}>' : '> $style '), + '${xml ? "XML" : "Markdown"} $style menu transforms the selected block', + ); + _check( + changed.contains(xml ? 'example' : '**example**') && + pane().target!.id == target, + '$style preserves existing inline formatting and the pinned inspector', + ); + await _native('chord', 'ctrl+z'); + await _pause(); + _check( + _source == before && + (_field('$style example.').widget as TextField).focusNode!.hasFocus, + '$style transformation has one Undo and retains content focus', + ); + await _native('chord', 'ctrl+shift+z'); + await _pause(); + _check( + _source == changed && + (_field('$style example.').widget as TextField).focusNode!.hasFocus, + '$style transformation Redo preserves source and content focus', + ); + } + } + + Future _runExplicitInspection() async { + final controller = ref.read(workspaceControllerProvider.notifier); + await controller.openPath('${widget.root.path}-contextual'); + final path = p.join( + '${widget.root.path}-contextual', + 'topics/explicit-inspection.md', + ); + await controller.openActiveFile(path); + await _native('chord', 'ctrl+shift+1'); + await _pause(); + BusyMarkWritersideProperties pane() => + _elements((w) => w is BusyMarkWritersideProperties).single.widget + as BusyMarkWritersideProperties; + Future sourceCaret(int offset) async { + ((_elements((w) => w is BusyMarkSourceEditor).single as StatefulElement) + .state + as BusyMarkSourceEditorState) + .scrollToOffset(offset); + await _pause(); + } + + Future property(String label, String value) async { + final entry = _inspectorProperty(label); + await _type( + _singleInspectorControl( + label, + (w) => w is TextField && w.controller == entry.controller, + ), + value, + ); + await _key(PhysicalKeyboardKey.enter, LogicalKeyboardKey.enter); + } + + await _tap(_field('Paragraph B.')); + final caret = + (_field('Paragraph B.').widget as TextField).controller!.selection; + final original = _source; + await _hoverProperties(_field('Chapter A')); + _check( + pane().target!.plainText == 'Chapter A' && + (_field('Paragraph B.').widget as TextField).controller!.selection == + caret && + _source == original, + 'Explicit chapter inspection is independent of the caret and source', + ); + await _tap(_field('Paragraph B.')); + await _native('chord', 'End'); + await _native('type', ' edited'); + await _pause(); + _check( + pane().target!.plainText == 'Chapter A' && + pane().document.source == _source, + 'Ordinary click and typing in B refresh the revision without retargeting A', + ); + await _tap(_field('Chapter A')); + await _native('chord', 'End'); + await _native('type', ' edited'); + await _pause(); + _check( + _inspectorProperty('Heading text').controller!.text == 'Chapter A edited', + 'Direct editing refreshes the pinned chapter title', + ); + await _native('chord', 'ctrl+z'); + await _pause(); + _check( + _inspectorProperty('Heading text').controller!.text == 'Chapter A', + 'Direct-edit Undo refreshes the pinned chapter before caret movement', + ); + final invalidAnchor = _inspectorProperty('Element anchor (id)'); + await _type( + _singleInspectorControl( + 'Element anchor (id)', + (w) => w is TextField && w.controller == invalidAnchor.controller, + ), + 'invalid id', + ); + await _tap(_field('Paragraph B. edited')); + await _native('chord', 'End'); + await _native('type', ' again'); + await _pause(); + _check( + pane().target!.plainText == 'Chapter A' && + invalidAnchor.controller!.text == 'invalid id' && + controller.propertyEdits.hasPending, + 'Ordinary Editor selection and typing retain the original invalid property draft', + ); + await _hoverProperties(_field('Paragraph B. edited again')); + _check( + pane().target!.plainText == 'Chapter A' && + invalidAnchor.controller!.text == 'invalid id', + 'Explicit Editor retargeting cannot transfer an invalid draft to B', + ); + invalidAnchor.focusNode!.requestFocus(); + await _native('chord', 'Escape'); + await _pause(); + _check( + pane().target!.plainText == 'Chapter A' && + pane().document.source == _source && + _inspectorProperty('Element anchor (id)').controller!.text.isEmpty, + 'Reverting a conflicting draft refreshes its original owner without caret movement', + ); + await _native('chord', 'ctrl+z'); + await _pause(); + _check( + _source.contains('Paragraph B. edited\n') && + pane().target!.plainText == 'Chapter A', + 'Undo of ordinary B typing preserves the pinned A owner', + ); + await _native('chord', 'ctrl+shift+4'); + await _pause(); + _check( + pane().target!.plainText == 'Chapter A', + 'Editor to Split retains the inspected chapter', + ); + await sourceCaret(_source.indexOf('Paragraph B.')); + _check( + pane().target!.plainText == 'Chapter A', + 'Split Source caret does not retarget inspection', + ); + await _requestProperties(); + _check( + pane().target!.plainText == 'Paragraph B. edited', + 'Explicit Split command inspects the current Source element', + ); + await _native('chord', 'ctrl+shift+1'); + await _pause(); + _check( + pane().target!.plainText == 'Paragraph B. edited', + 'Split to Editor retains the inspected paragraph', + ); + await _tapLabel('Show in Source'); + await _pause(); + _check( + pane().target!.plainText == 'Paragraph B. edited' && + ref + .read(workspaceControllerProvider) + .activeBuffer! + .editorState + .selection + .extentOffset == + _source.indexOf('Paragraph B.'), + 'Compact Show in Source navigates exactly without retargeting', + ); + await sourceCaret(_source.indexOf('Other paragraph.')); + _check( + pane().target!.plainText == 'Paragraph B. edited', + 'Source caret and focus do not change the inspected paragraph', + ); + await _requestProperties(); + _check( + pane().target!.plainText == 'Other paragraph.', + 'Open inspector command explicitly retargets Source selection', + ); + final beforeAnchor = _source; + await property('Element anchor (id)', 'other-anchor'); + final afterAnchor = _source; + await _native('chord', 'ctrl+z'); + await _pause(); + _check( + _source == beforeAnchor && + _inspectorProperty('Element anchor (id)').controller!.text.isEmpty && + _inspectorProperty('Element anchor (id)').focusNode!.hasFocus && + pane().target!.plainText == 'Other paragraph.', + 'Pinned paragraph anchor Undo restores target and value immediately', + ); + await _native('chord', 'ctrl+shift+z'); + await _pause(); + _check( + _source == afterAnchor && + _inspectorProperty('Element anchor (id)').controller!.text == + 'other-anchor', + 'Pinned paragraph anchor Redo restores target and value immediately', + ); + final draft = _inspectorProperty('Element anchor (id)'); + await _type( + _singleInspectorControl( + 'Element anchor (id)', + (w) => w is TextField && w.controller == draft.controller, + ), + 'invalid id', + ); + await _native('chord', 'ctrl+shift+1'); + await _pause(); + _check( + _elements((w) => w is BusyMarkSourceEditor).isNotEmpty && + controller.propertyEdits.hasPending, + 'Invalid draft still blocks a view transition', + ); + await sourceCaret(_source.indexOf('Chapter B')); + await _requestProperties(); + _check( + pane().target!.plainText == 'Other paragraph.' && + draft.controller!.text == 'invalid id' && + _source == afterAnchor, + 'Explicit Source retarget rejects rather than transfers an invalid draft', + ); + draft.focusNode!.requestFocus(); + await _native('chord', 'Escape'); + await _pause(); + await property('Element anchor (id)', ''); + final beforeDelete = _source; + await sourceCaret(_source.indexOf('Other paragraph.')); + await _native('chord', 'Home'); + await _native('chord', 'shift+End'); + await _native('key', 'Delete'); + await _pause(); + _check( + _elements((w) => w is BusyMarkWritersideProperties).isEmpty && + _elements( + (w) => + w is Text && + w.data == + 'The inspected element is unavailable. Inspect an element to continue.', + ).isNotEmpty, + 'Deleting the inspected paragraph invalidates it instead of selecting a neighbor', + ); + await _native('chord', 'ctrl+z'); + await _pause(); + _check( + _source == beforeDelete && pane().target!.plainText == 'Other paragraph.', + 'Undo restores the verified inspected paragraph without a caret event', + ); + await _native('chord', 'ctrl+shift+1'); + await _pause(); + await _hoverProperties(_field('Child item')); + await _tap( + _singleInspectorControl( + 'Element', + (w) => w is BusyMarkComboRow && w.title == 'Element', + ), + ); + _nativeMenuOpen = true; + final parentList = pane().path.lastWhere( + (b) => b.attributes['element'] == 'list', + ); + final selector = + _singleInspectorControl( + 'Element', + (w) => w is BusyMarkComboRow && w.title == 'Element', + ).widget + as BusyMarkComboRow; + await _tapLabel(selector.labelFor(parentList.id)); + final listId = pane().target!.id; + await _tap(_field('Parent item')); + _check( + pane().target!.id == listId && + pane().target!.attributes['element'] == 'list', + 'Ordinary item selection retains the explicitly inspected parent list', + ); + await _captureInspectorLayouts('explicit-list'); + for (final reference in [ + ( + 'Multiline Copy and Copy.', + 11, + '', + r'key="$Save" from-keymap-of="conformance">', + ), + 'Adding a mapped multiline reference attribute preserves quote/list prefixes', + ); + await property('Keymap help instance (from-keymap-of)', ''); + _check( + _source == beforeKeymap, + 'Removing a mapped multiline reference attribute restores exact source', + ); + await _native('chord', 'ctrl+z'); + await _pause(); + _check( + _source == withKeymap && + _inspectorProperty( + 'Keymap help instance (from-keymap-of)', + ).controller!.text == + 'conformance', + 'Multiline attribute removal Undo restores the same owner and authored value', + ); + await _native('chord', 'ctrl+shift+z'); + await _pause(); + _check( + _source == beforeKeymap && + _inspectorProperty( + 'Keymap help instance (from-keymap-of)', + ).controller!.text.isEmpty, + 'Multiline attribute removal Redo restores absence on the same owner', + ); + await _native('chord', 'ctrl+shift+1'); + await _pause(); + await _runSemanticMenuCommands(false); + await _native('chord', 'ctrl+s'); + await _until( + () => !ref.read(workspaceControllerProvider).activeBuffer!.isDirty, + 'Explicit inspection source saved', + ); + final saved = _source; + await _native('chord', 'ctrl+w'); + await _pause(); + await controller.openActiveFile(path); + await _pause(); + _check( + _source == saved && + _elements((w) => w is BusyMarkWritersideProperties).isEmpty, + 'Save and reopen preserve source and require explicit inspection in a new buffer', + ); + await _native('chord', 'ctrl+shift+2'); + await _pause(); + await sourceCaret(_source.indexOf(' w is BusyMarkWritersideProperties).isEmpty, + 'Changing to XML clears the previous editable target', + ); + await _hoverProperties(_field('Semantic insertion.')); + await _runSemanticMenuCommands(true); + await _native('chord', 'ctrl+s'); + await _until( + () => !ref.read(workspaceControllerProvider).activeBuffer!.isDirty, + 'XML semantic commands save through the production UI', + ); + final savedXml = _source; + await _native('chord', 'ctrl+w'); + await _pause(); + await controller.openActiveFile(xmlPath); + await _pause(); + _check( + _source == savedXml && + savedXml.contains('Open & Files') && + savedXml.contains('docs/example.txt') && + savedXml.contains('File | Open') && + savedXml.contains('Ctrl+M'), + 'Every XML semantic command survives save close reopen with literal shortcut content', + ); + await _closeInspector(); + await controller.openPath(widget.root.path); + } + + Future _runSourceInvalidation() async { + final controller = ref.read(workspaceControllerProvider.notifier); + await controller.openPath('${widget.root.path}-contextual'); + for (final (xml, malformed, draft) in _invalidationCases) { + final kind = draft + ? '${xml ? "XML" : "Markdown"} pending draft' + : malformed + ? 'XML parse failure' + : xml + ? 'XML' + : 'Markdown'; + final path = p.join( + '${widget.root.path}-contextual', + 'topics/${_invalidationStem(xml, malformed, draft)}.${xml ? 'topic' : 'md'}', + ); + final original = _invalidationSource(xml, malformed, draft); + _check( + await controller.openActiveFile(path) && _source == original, + '$kind Source invalidation fixture opens with its original source', + ); + await _native('chord', 'ctrl+shift+2'); + await _pause(); + Future caret(int offset) async { + ((_elements((w) => w is BusyMarkSourceEditor).single as StatefulElement) + .state + as BusyMarkSourceEditorState) + .scrollToOffset(offset); + await _pause(); + } + + BusyMarkWritersideProperties pane() => + _elements((w) => w is BusyMarkWritersideProperties).single.widget + as BusyMarkWritersideProperties; + void unavailable(String stage) { + if (controller.propertyEdits.hasPending) { + _check( + pane().target!.plainText == 'Original owner' && + _inspectorProperty('Element anchor (id)').controller!.text == + 'invalid id', + '$kind Source invalidation: $stage preserves the original draft owner', + ); + return; + } + _check( + _elements((w) => w is BusyMarkWritersideProperties).isEmpty && + _elements( + (w) => + w is Text && + w.data == + 'The inspected element is unavailable. Inspect an element to continue.', + ).isNotEmpty, + '$kind Source invalidation: $stage leaves the original owner unavailable', + ); + } + + await caret(_source.indexOf('Original owner') + 3); + await _requestProperties(); + _check( + pane().target!.plainText == 'Original owner', + '$kind Source invalidation: explicit command selects the original owner', + ); + await caret(_source.indexOf('Original owner') + 'Original owner'.length); + await Clipboard.setData(const ClipboardData(text: ' edited')); + await _native('chord', 'ctrl+v'); + await _pause(); + _check( + pane().target!.plainText == 'Original owner edited', + '$kind Source invalidation: editing a surviving owner still refreshes it', + ); + await _native('chord', 'ctrl+z'); + await _pause(); + _check( + _source == original && pane().target!.plainText == 'Original owner', + '$kind Source invalidation: surviving-owner Undo refreshes immediately', + ); + if (draft) { + final anchor = _inspectorProperty('Element anchor (id)'); + await _type( + _singleInspectorControl( + 'Element anchor (id)', + (w) => w is TextField && w.controller == anchor.controller, + ), + 'invalid id', + ); + } + await caret(_source.indexOf('Original owner')); + await _native('chord', 'Home'); + await _native('chord', 'shift+End'); + if (malformed) { + await Clipboard.setData( + const ClipboardData(text: '

    Replacement owner'), + ); + await _native('chord', 'ctrl+v'); + } else { + await _native('key', 'Delete'); + } + await _pause(); + final deleted = _source; + unavailable(malformed ? 'unparseable replacement' : 'deletion'); + await Clipboard.setData( + ClipboardData( + text: malformed + ? '

    ' + : xml + ? '

    Replacement owner

    ' + : 'Replacement owner', + ), + ); + await _native('chord', 'ctrl+v'); + await _pause(); + final replacement = _source; + _check( + replacement == + original.replaceFirst('Original owner', 'Replacement owner'), + '$kind Source replacement paste is one exact native insertion', + ); + unavailable('same-kind replacement insertion'); + await caret(_source.indexOf('Other owner')); + unavailable('ordinary Source caret movement'); + if (draft) { + _inspectorProperty('Element anchor (id)').focusNode!.requestFocus(); + await _native('chord', 'Escape'); + await _pause(); + _check( + !controller.propertyEdits.hasPending, + '$kind Source draft reverts without publication', + ); + unavailable('discarding the original draft after replacement'); + await caret(_source.indexOf('Other owner')); + } + await _native('chord', 'ctrl+z'); + await _pause(); + _check(_source == deleted, '$kind Source replacement insertion Undo'); + unavailable('Undo to the deleted representation'); + await _native('chord', 'ctrl+z'); + await _pause(); + _check( + _source == original && pane().target!.plainText == 'Original owner', + '$kind Source invalidation: genuine history Undo restores the original owner immediately', + ); + await _native('chord', 'ctrl+shift+z'); + await _pause(); + _check(_source == deleted, '$kind Source original deletion Redo'); + unavailable('Redo of deletion'); + await _native('chord', 'ctrl+shift+z'); + await _pause(); + _check(_source == replacement, '$kind Source replacement insertion Redo'); + unavailable('Redo to a replacement that never owned inspection'); + await caret(_source.indexOf('Replacement owner') + 3); + await _requestProperties(); + _check( + pane().target!.plainText == 'Replacement owner', + '$kind Source invalidation: explicit Properties selects the replacement', + ); + await _native('chord', 'ctrl+s'); + await _until( + () => !ref.read(workspaceControllerProvider).activeBuffer!.isDirty, + '$kind Source replacement saved', + ); + _check( + await File(path).readAsString() == replacement, + '$kind Source replacement save preserves exact authored source', + ); + await _native('chord', 'ctrl+w'); + await _until( + () => !ref + .read(workspaceControllerProvider) + .documentBuffers + .any((buffer) => buffer.filePath == path), + '$kind Source replacement closes before reopen', + ); + _check( + await controller.openActiveFile(path), + '$kind saved replacement opens successfully', + ); + await _pause(); + _check( + _source == replacement && + _elements((w) => w is BusyMarkWritersideProperties).isEmpty, + '$kind Source replacement reopen has no carried inspector owner', + ); + await _native('chord', 'ctrl+shift+2'); + await _until( + () => _elements((w) => w is BusyMarkSourceEditor).length == 1, + '$kind reopened replacement is ready in Source', + ); + await caret(_source.indexOf('Replacement owner') + 3); + await _requestProperties(); + _check( + pane().target!.plainText == 'Replacement owner', + '$kind Source replacement can be explicitly inspected after reopen', + ); + await _native('chord', 'ctrl+w'); + await _until( + () => !ref + .read(workspaceControllerProvider) + .documentBuffers + .any((buffer) => buffer.filePath == path), + '$kind reopened replacement closes before the next scenario', + ); + } + await _closeInspector(); + await controller.openPath(widget.root.path); + } + + Future _runNestedListReferences() async { + final controller = ref.read(workspaceControllerProvider.notifier); + await controller.openPath('${widget.root.path}-contextual'); + final path = p.join( + '${widget.root.path}-contextual', + 'topics/nested-references.md', + ); + await controller.openActiveFile(path); + await _native('chord', 'ctrl+shift+1'); + await _until( + () => _elements( + (w) => w is BusyMarkWysiwygEditor && w.document.filePath == path, + ).isNotEmpty, + 'Nested reference fixture opens in the production Editor', + ); + BusyMarkWritersideProperties pane() => + _elements((w) => w is BusyMarkWritersideProperties).single.widget + as BusyMarkWritersideProperties; + Future sourceAt(int at) async { + await _native('chord', 'ctrl+shift+2'); + await _pause(); + ((_elements((w) => w is BusyMarkSourceEditor).single as StatefulElement) + .state + as BusyMarkSourceEditorState) + .scrollToOffset(at); + await _pause(); + await _requestProperties(); + } + + Future occurrence( + String text, + int position, + int authored, + BusyInlineKind kind, + String label, + ) async { + await _native('chord', 'ctrl+shift+1'); + await _pause(); + await _tap(_field(text)); + (_field(text).widget as TextField).controller!.selection = + TextSelection.collapsed(offset: position + 1); + await _pause(); + await _requestProperties(); + _check( + pane().inlineTarget?.kind == kind && + pane().inlineTarget?.start == position, + '$label Editor identifies its own reference occurrence', + ); + if (label == 'Links occurrence 0' || + label == 'images occurrence 0' || + label == 'keys occurrence 0') { + await _captureInspectorLayouts('form-inline-${kind.name}'); + } + await _tapLabel('Show in Source'); + await _pause(); + _check( + ref + .read(workspaceControllerProvider) + .activeBuffer! + .editorState + .selection + .extentOffset == + authored, + '$label Show in Source reaches exact authored markup', + ); + await sourceAt(authored + 2); + _check( + pane().inlineTarget?.kind == kind && + pane().inlineTarget?.start == position, + '$label Source selects the same reference owner', + ); + } + + var original = _source; + await _hoverProperties(_field('Child product and product')); + final cases = [ + ( + 'Parent product in parent', + 'Parent ', + [7], + BusyInlineKind.writersideVariable, + '%product%', + ), + ( + 'Child product and product', + 'Child ', + [6, 18], + BusyInlineKind.writersideVariable, + '%product%', + ), + ( + 'continuation product and product', + 'continuation ', + [13, 25], + BusyInlineKind.writersideVariable, + '%product%', + ), + ( + 'Descendant product', + 'Descendant ', + [11], + BusyInlineKind.writersideVariable, + '%product%', + ), + ( + 'Quote parent product', + 'Quote parent ', + [13], + BusyInlineKind.writersideVariable, + '%product%', + ), + ( + 'Quote child product and product', + 'Quote child ', + [12, 24], + BusyInlineKind.writersideVariable, + '%product%', + ), + ( + 'Links Same Same, images Icon Icon, keys Copy Copy.', + 'Links ', + [6, 11], + BusyInlineKind.link, + '[Same]', + ), + ( + 'Links Same Same, images Icon Icon, keys Copy Copy.', + 'images ', + [24, 29], + BusyInlineKind.image, + '![Icon]', + ), + ( + 'Links Same Same, images Icon Icon, keys Copy Copy.', + 'keys ', + [40, 45], + BusyInlineKind.writersideShortcut, + 'XML ', + [4, 15, 22, 30], + BusyInlineKind.writersideVariable, + '%product%', + ), + ]; + for (final item in cases) { + var cursor = _source.indexOf(item.$2); + for (var i = 0; i < item.$3.length; i++) { + final at = _source.indexOf(item.$5, cursor); + cursor = at + item.$5.length; + _check(at >= 0, '${item.$2} authored occurrence $i exists'); + await occurrence( + item.$1, + item.$3[i], + at, + item.$4, + '${item.$2} occurrence $i', + ); + } + } + await _capture('nested-references-source'); + final paragraph = original.indexOf('

    XML '); + final first = original.indexOf('%product%', paragraph); + final second = original.indexOf('%product%', first + 1); + final third = original.indexOf('%product%', second + 1); + await sourceAt(third + 2); + final entry = _inspectorProperty('Variable Reference'); + await _type( + _elements( + (w) => w is TextField && w.controller == entry.controller, + ).single, + 'long_product_name', + ); + await _key(PhysicalKeyboardKey.enter, LogicalKeyboardKey.enter); + final changed = original.replaceRange( + third, + third + '%product%'.length, + '%long_product_name%', + ); + _check( + _source == changed, + 'Nested XML third variable changes while all other references remain byte-identical', + ); + // Ctrl+Z in a property text entry belongs to that entry's draft. Use the + // existing navigation action to focus the document before document Undo; + // after Undo/Redo, inspect the restored target before any further input. + await _tapLabel('Show in Source'); + await _pause(); + _check( + _source == changed && + ref + .read(workspaceControllerProvider) + .activeBuffer! + .editorState + .selection + .extentOffset == + third && + FocusManager.instance.primaryFocus?.context + ?.findAncestorWidgetOfExactType() != + null, + 'Nested reference document Undo starts in Source at the inspected occurrence without another edit', + ); + await _native('chord', 'ctrl+z'); + await _pause(); + _check( + _source == original && + _inspectorProperty('Variable Reference').controller!.text == + 'product', + 'Nested reference Undo immediately restores source and inspector without caret movement', + ); + await _native('chord', 'ctrl+shift+z'); + await _pause(); + _check( + _source == changed && + _inspectorProperty('Variable Reference').controller!.text == + 'long_product_name', + 'Nested reference Redo immediately restores source and inspector', + ); + await _native('chord', 'ctrl+s'); + await _until( + () => !ref.read(workspaceControllerProvider).activeBuffer!.isDirty, + 'Nested reference source saves', + ); + final buffer = ref.read(workspaceControllerProvider).activeBuffer!; + _check( + await File(path).readAsString() == changed, + 'Nested references persist exactly', + ); + _check( + await controller.closeDocumentBuffer(buffer.id), + 'Nested reference document closes', + ); + await controller.openActiveFile(path); + await _pause(); + await occurrence( + 'XML productIconproductlong_product_name\nproduct', + 22, + third, + BusyInlineKind.writersideVariable, + 'Reopened nested third variable', + ); + _check( + _source == changed, + 'Nested reference save/reopen remains source-neutral', + ); + await _native('chord', 'ctrl+shift+1'); + await _pause(); + await _hoverProperties( + _field('Links Same Same, images Icon Icon, keys Copy Copy.'), + ); + await _capture('nested-references-item-editor'); + await _tap( + _singleInspectorControl( + 'Close', + (w) => w is BusyMarkHeaderIconButton && w.tooltip == 'Close', + ), + ); + await _until( + () => !ref.read(appSettingsControllerProvider).elementPropertiesVisible, + 'Nested reference journey closes the inspector through its guarded header', + ); + await controller.openPath(widget.root.path); + await _pause(); + } + + Future _run() async { + Object? failure; + try { + await _until( + () => + ref.read(workspaceControllerProvider).workspace?.rootPath == + widget.root.path, + 'Fixture workspace loaded', + ); + final controller = ref.read(workspaceControllerProvider.notifier); + final settings = ref.read(appSettingsControllerProvider.notifier); + await _startAccessibilitySession(); + await settings.waitUntilLoaded(); + _check( + !ref.read(appSettingsControllerProvider).elementPropertiesVisible && + ref.read(appSettingsControllerProvider).elementPropertiesWidth == + 390, + 'Startup ignores old visible preference and preserves width', + ); + await _capture('inspector-startup-closed'); + Future paletteProperties() => _requestProperties(); + + if (![ + 'BUSYMARK_INLINE_REFERENCES_ONLY', + 'BUSYMARK_VARIABLE_MEDIA_ONLY', + 'BUSYMARK_NAVIGATION_ONLY', + 'BUSYMARK_PROPERTIES_ONLY', + ].any((flag) => Platform.environment[flag] == '1')) { + await _runSourceInvalidation(); + await _runChapterPresentation(); + await _runContextualListProperties(paletteProperties); + await _runNestedListReferences(); + await _runExplicitInspection(); + } + if (Platform.environment['BUSYMARK_PROPERTY_CONTRACT_ONLY'] == '1') { + await _runPropertyContract(paletteProperties); + await _finish(null); + return; + } + if (Platform.environment['BUSYMARK_INLINE_REFERENCES_ONLY'] == '1' || + Platform.environment['BUSYMARK_VARIABLE_MEDIA_ONLY'] == '1') { + await _runInlineReferences(paletteProperties); + await _finish(null); + return; + } + + await controller.openActiveFile( + p.join(widget.root.path, 'topics/authoring.topic'), + ); + await _native('chord', 'ctrl+shift+1'); + await _pause(); + await _until( + () => _elements( + (w) => w is TextField && w.controller?.text == 'print(1);', + ).isNotEmpty, + 'Language fixture opens in Editor', + ); + await _tap(_field('print(1);')); + _check( + !ref.read(appSettingsControllerProvider).elementPropertiesVisible, + 'Ordinary selection does not reopen inspector', + ); + await _native('chord', 'ctrl+shift+k'); + await _until( + () => _elements( + (w) => + w is BusyMarkWritersideProperties && + w.target?.kind == BusyBlockKind.codeBlock, + ).isNotEmpty, + 'Language command exposes workspace code properties', + ); + _check( + _elements((w) => w is BusyMarkWritersideProperties).length == 1 && + _elements( + (w) => + w is BusyMarkGroupedTextEntry && w.label == 'Language (lang)', + ).isNotEmpty, + 'Hosted language command opens selected code language control', + ); + await _capture('inspector-workspace-language-command'); + final languageEntry = + _elements( + (w) => + w is BusyMarkGroupedTextEntry && + w.label == 'Language (lang)', + ).single.widget + as BusyMarkGroupedTextEntry; + await _type( + _elements( + (w) => w is TextField && w.controller == languageEntry.controller, + ).single, + 'kotlin', + ); + await _tapLabel('Show in Source'); + await _until( + () => _elements((w) => w is BusyMarkSourceEditor).isNotEmpty, + 'Show in Source processes pending language draft', + ); + _check( + ref + .read(workspaceControllerProvider) + .activeBuffer! + .editorState + .selection + .extentOffset == + _source.indexOf('() != + null, + 'Show in Source focuses the inspected markup', + ); + _check( + _source.contains('lang="kotlin"') && _source.contains('print(1);'), + 'Language field changes lang while preserving authored code', + ); + _check( + controller.undoActiveBuffer(), + 'Language edit has existing workspace undo', + ); + await _pause(); + _check( + _source.contains('lang="dart"'), + 'Language undo restores src-independent block', + ); + if (Platform.environment['BUSYMARK_NAVIGATION_ONLY'] == '1') { + await _finish(null); + return; + } + await _closeInspector(); + for (final suffix + in Platform.environment['BUSYMARK_PROPERTIES_ONLY'] == '1' + ? [] + : ['topic', 'md']) { + await controller.openActiveFile( + p.join( + widget.root.path, + 'topics/${suffix == 'md' ? 'authoring-markdown.md' : 'authoring.topic'}', + ), + ); + await _native('chord', 'ctrl+shift+1'); + await _until( + () => _elements((w) => w is BusyMarkWysiwygEditor).isNotEmpty, + 'Editor available for $suffix', + ); + await _pause(); + final body = _field('Text'); + stdout.writeln( + 'Native text scale: ${MediaQuery.textScalerOf(body).scale(14) / 14}', + ); + await _tap(body); + (body.widget as TextField).controller!.selection = const TextSelection( + baseOffset: 0, + extentOffset: 4, + ); + await _pause(); + final beforeMenu = _source; + final selected = (body.widget as TextField).controller!.selection; + await _tap( + _elements( + (w) => w.key == const ValueKey('wysiwyg-writerside'), + ).single, + ); + _nativeMenuOpen = true; + await _native('key', 'Down'); + await _capture('authoring-$suffix-insert-menu'); + await _native('key', 'Escape'); + _nativeMenuOpen = false; + await _pause(); + stdout.writeln( + 'GTK dismissal: source unchanged=${_source == beforeMenu}; ' + 'selection before=$selected after=${(body.widget as TextField).controller!.selection}; ' + 'editing focus=${(body.widget as TextField).focusNode!.hasFocus}; ' + 'primary focus=${FocusManager.instance.primaryFocus?.debugLabel}', + ); + _check( + _source == beforeMenu && + (body.widget as TextField).controller!.selection == selected && + (body.widget as TextField).focusNode!.hasFocus, + '$suffix GTK keyboard dismissal preserves source, selection and focus', + ); + await _choose('wysiwyg-writerside', ['Semantic text', 'UI Control']); + _check( + _source.contains('Text'), + '$suffix semantic formatting authors control', + ); + _keyData( + PhysicalKeyboardKey.controlLeft, + LogicalKeyboardKey.controlLeft, + ui.KeyEventType.down, + ); + await _key(PhysicalKeyboardKey.keyZ, LogicalKeyboardKey.keyZ); + _keyData( + PhysicalKeyboardKey.controlLeft, + LogicalKeyboardKey.controlLeft, + ui.KeyEventType.up, + ); + _check( + !_source.contains(''), + '$suffix semantic action undo is one buffer operation', + ); + _keyData( + PhysicalKeyboardKey.controlLeft, + LogicalKeyboardKey.controlLeft, + ui.KeyEventType.down, + ); + _keyData( + PhysicalKeyboardKey.shiftLeft, + LogicalKeyboardKey.shiftLeft, + ui.KeyEventType.down, + ); + await _key(PhysicalKeyboardKey.keyZ, LogicalKeyboardKey.keyZ); + _keyData( + PhysicalKeyboardKey.shiftLeft, + LogicalKeyboardKey.shiftLeft, + ui.KeyEventType.up, + ); + _keyData( + PhysicalKeyboardKey.controlLeft, + LogicalKeyboardKey.controlLeft, + ui.KeyEventType.up, + ); + _check( + _source.contains('Text'), + '$suffix semantic redo preserves type', + ); + await _choose('wysiwyg-writerside', ['Insert elements', 'Procedure']); + await _type(_field(''), 'Step content'); + _check( + _source.contains(' w is BusyMarkGroupedTextEntry && w.label == 'Source', + (w) => w is BusyMarkGroupedTextEntry && w.label == 'Source (src)', ).single; await _tap(videoEntry); final videoField = videoEntry.widget as BusyMarkGroupedTextEntry; @@ -509,190 +3757,1371 @@ class _HarnessState extends ConsumerState<_Harness> { await _pause(); await _key(PhysicalKeyboardKey.enter, LogicalKeyboardKey.enter); _check( - _source.contains('

    '), + 'Explicit inspected B receives the edit', + ); + await _until( + () => + _elements( + (w) => + w is BusyMarkWritersideProperties && + w.target?.plainText == 'Installation' && + w.document.source == _source, + ).length == + 1, + 'Property publication preserves inspected B while caret stays in A', + ); + _check( + controller.undoActiveBuffer(), + 'Explicit B property has one undo', + ); + await _pause(); + _check( + _source.contains('title="Block B"'), + 'Undo restores inspected B', + ); + await _until( + () => + _elements( + (w) => + w is BusyMarkWritersideProperties && + w.target?.plainText == 'Block B' && + w.document.source == _source, + ).length == + 1, + 'Undo preserves explicitly inspected B at the new revision', + ); + _check( + _inspectorProperty('Default title (title)').controller!.text == + 'Block B' && + (_field('Caret A').widget as TextField) + .controller! + .selection + .baseOffset == + caret.baseOffset && + (_field('Caret A').widget as TextField) + .controller! + .selection + .extentOffset == + caret.extentOffset && + (_field('Caret A').widget as TextField).focusNode!.hasFocus, + 'Off-caret B inspection survives Undo without selecting A', + ); + } + await _native('chord', 'ctrl+shift+2'); + await _until( + () => _elements((w) => w is BusyMarkSourceEditor).isNotEmpty, + 'Source inspector loaded', + ); + _check( + _elements((w) => w is BusyMarkWysiwygEditor).isEmpty, + 'Source inspector has no hidden WYSIWYG', + ); + final sourceElement = + _elements((w) => w is BusyMarkSourceEditor).single + as StatefulElement; + final sourceState = sourceElement.state as BusyMarkSourceEditorState; + final codeOffset = suffix == 'topic' + ? _source.indexOf(' w is BusyMarkWritersideProperties).single.widget + as BusyMarkWritersideProperties; + _check( + properties.target!.kind == BusyBlockKind.codeBlock && + properties.path.any((b) => b.plainText == 'Nested'), + '$suffix Source caret resolves code and chapter ancestry', + ); + await _capture('inspector-$suffix-source-code'); + BusyMarkGroupedTextEntry property(String label) => + _inspectorProperty(label); + final sourceBeforeChooser = _source; + await _capture('inspector-single-source-field'); + Element sourceButton(String tooltip) => + _elements( + (w) => w is BusyMarkHeaderIconButton && w.tooltip == tooltip, + ).singleWhere( + (e) => + e + .findAncestorWidgetOfExactType() + ?.label == + 'Source file (src)', + ); + await _tap(sourceButton('Open')); + await _pause(); + await _capture('inspector-native-source-chooser'); + await _native('chord', 'Escape'); + await _pause(); + _check( + _source == sourceBeforeChooser, + '$suffix native file selection cancellation changes nothing', + ); + await _tap(sourceButton('Remove')); + await _pause(); + _check( + !_source.contains('src="inspector-sample.txt"') && + !_source.contains('first\nsecond'), + '$suffix removing src does not import file bytes', + ); + await _tap(sourceButton('Open')); + await _pause(); + await _native('chord', 'ctrl+l'); + await _native( + 'type', + p.join(widget.root.path, 'topics/inspector-sample.txt'), + ); + await _native('chord', 'Return'); + await _pause(); + await _until( + () => _source == sourceBeforeChooser, + '$suffix native file choice edits the same src transaction', + ); + _check( + (_elements( + (w) => + w is BusyMarkGroupedTextEntry && + w.label == 'Source file (src)', + ).single.widget + as BusyMarkGroupedTextEntry) + .controller! + .text == + 'inspector-sample.txt', + 'Source field displays chosen topic-relative reference', + ); + await _capture('inspector-source-file-selected'); + await _closeInspector(); + _check( + !ref.read(appSettingsControllerProvider).elementPropertiesVisible, + 'Close control uses the workspace draft handler', + ); + await paletteProperties(); + _check( + ref.read(appSettingsControllerProvider).elementPropertiesVisible, + 'Palette opens one workspace inspector', + ); + final id = property('Element anchor (id)'); + await _tap( + _elements( + (w) => w is TextField && w.controller == id.controller, + ).single, + ); + final beforeHistory = ref + .read(workspaceControllerProvider) + .activeBuffer! + .editorState + .undoState + .undo + .length; + id.controller!.text = 'saved-code'; + await _native('chord', 'ctrl+s'); + await _until(() { + final current = ref.read(workspaceControllerProvider).activeBuffer; + return current != null && + !current.isDirty && + File( + current.filePath!, + ).readAsStringSync().contains('id="saved-code"'); + }, '$suffix focused native Save reaches disk'); + final buffer = ref.read(workspaceControllerProvider).activeBuffer!; + _check( + (await File( + buffer.filePath!, + ).readAsString()).contains('id="saved-code"'), + '$suffix native Ctrl+S commits focused property before saving', + ); + _check( + buffer.editorState.undoState.undo.length == beforeHistory + 1, + '$suffix property commit records one undo', + ); + _check( + buffer.editorState.selection.extentOffset == codeOffset, + '$suffix Source selection survives property transaction', + ); + final invalidId = property('Element anchor (id)'); + invalidId.focusNode!.requestFocus(); + invalidId.controller!.text = 'invalid id'; + final beforeInvalid = _source; + await _native('chord', 'ctrl+s'); + await _pause(); + _check( + _source == beforeInvalid && controller.propertyEdits.hasPending, + '$suffix invalid draft blocks Save and retains its target', + ); + await _native('chord', 'ctrl+shift+3'); + await _pause(); + _check( + _elements((w) => w is BusyMarkSourceEditor).isNotEmpty, + '$suffix invalid draft cancels view transition', + ); + await _tap( + _elements( + (w) => w is BusyMarkHeaderIconButton && w.tooltip == 'Close', + ) + .where( + (e) => + e + .findAncestorWidgetOfExactType< + BusyMarkWritersideProperties + >() != + null, + ) + .single, + ); + _check( + _elements((w) => w is BusyMarkWritersideProperties).isNotEmpty && + controller.propertyEdits.hasPending, + '$suffix invalid draft cancels inspector closure', + ); + await _capture('inspector-$suffix-invalid-draft'); + await paletteProperties(); + _check( + ref.read(appSettingsControllerProvider).elementPropertiesVisible && + controller.propertyEdits.hasPending, + 'Palette close retains invalid draft and inspector', + ); + invalidId.focusNode!.requestFocus(); + await _pause(); + await _key(PhysicalKeyboardKey.escape, LogicalKeyboardKey.escape); + _check( + !controller.propertyEdits.hasPending && + invalidId.controller!.text == 'saved-code', + '$suffix Escape explicitly reverts pending text', + ); + final extraction = property('Include lines (include-lines)'); + await _tap( + _elements( + (w) => w is TextField && w.controller == extraction.controller, + ).single, + ); + extraction.controller!.text = '2-3'; + await _key(PhysicalKeyboardKey.enter, LogicalKeyboardKey.enter); + final emphasis = property( + 'Emphasize displayed lines (emphasize-lines)', + ); + await _tap( + _elements( + (w) => w is TextField && w.controller == emphasis.controller, + ).single, + ); + emphasis.controller!.text = '1'; + await _key(PhysicalKeyboardKey.enter, LogicalKeyboardKey.enter); + await _capture('inspector-$suffix-code-properties'); + _check(await controller.saveActive(), '$suffix Source inspector save'); + _check( + await controller.closeDocumentBuffer(buffer.id), + '$suffix Source inspector close', + ); + await controller.openActiveFile(buffer.filePath!); + await _native('chord', 'ctrl+shift+2'); + await _until( + () => _elements((w) => w is BusyMarkSourceEditor).isNotEmpty, + 'Reopened Source inspector', + ); + final reopened = + (_elements((w) => w is BusyMarkSourceEditor).single + as StatefulElement) + .state + as BusyMarkSourceEditorState; + reopened.scrollToOffset( + _source.indexOf(suffix == 'topic' ? ' _elements( + (w) => + w is BusyMarkGroupedTextEntry && + w.label == 'Element anchor (id)' && + w.controller?.text == 'saved-code', + ).isNotEmpty, + '$suffix reopened Source caret resolves saved code', ); - await _tap(_field('Text')); - await _choose('wysiwyg-writerside-insert', [ - 'Include Reusable Content', - 'features.topic', - 'shared-conformance', - ]); _check( - _source.contains('element-id="shared-conformance"'), - '$suffix include remains authored reference', + property('Element anchor (id)').controller!.text == 'saved-code' && + property('Include lines (include-lines)').controller!.text == + '2-3' && + property( + 'Emphasize displayed lines (emphasize-lines)', + ).controller!.text == + '1', + '$suffix saved code properties survive close, reopen and reinspection', ); - await _tap(_field('Text')); - (_field('Text').widget as TextField).controller!.selection = - const TextSelection.collapsed(offset: 4); + await _native('chord', 'ctrl+shift+4'); await _pause(); - await _choose('wysiwyg-writerside-insert', [ - 'Variable Reference', - 'product', - ]); + await _capture('inspector-$suffix-split'); + await _native('chord', 'ctrl+shift+2'); + await _pause(); + final initial = _source; + final delayedProperties = + _elements((w) => w is BusyMarkWritersideProperties).single.widget + as BusyMarkWritersideProperties; + final liveSourceState = + (_elements((w) => w is BusyMarkSourceEditor).single + as StatefulElement) + .state + as BusyMarkSourceEditorState; + liveSourceState.scrollToOffset( + _source.indexOf(suffix == 'topic' ? ' _elements( + (w) => + w is BusyMarkWritersideProperties && + w.target?.attributes['element'] != 'code-block' && + w.target?.kind != BusyBlockKind.codeBlock, + ).isNotEmpty, + '$suffix Source retargets to chapter', + ); _check( - _source.contains('%product%'), - '$suffix variable remains authored reference', + !delayedProperties.onProperty('collapsed-title', 'Delayed result') && + _source == initial, + '$suffix delayed completion rejects a different target in the same unchanged buffer', ); - const switcherLabel = r'''Author's: "Desktop" \ Keys'''; - final beforeLabel = _source; - final selectedField = _field('Textproduct').widget as TextField; - final selection = selectedField.controller!.selection; - Future topicProperties() async { - await _tap( + liveSourceState.scrollToOffset( + _source.indexOf(suffix == 'topic' ? ' _elements( + (w) => + w is BusyMarkWritersideProperties && + w.target?.kind == BusyBlockKind.codeBlock, + ).isNotEmpty, + '$suffix Source restores code target', + ); + await _tap( + _elements( + (w) => + w is BusyMarkComboRow && + w.title == 'Collapsible (collapsible)', + ).single, + ); + _nativeMenuOpen = true; + await _tapLabel('Enabled'); + await _capture('inspector-$suffix-override'); + await _tap( + _elements( + (w) => + w is BusyMarkComboRow && + w.title == 'Collapsible (collapsible)', + ).single, + ); + _nativeMenuOpen = true; + await _tapLabel('Use default'); + _check( + _source == initial, + '$suffix reset removes authored collapse and restores source', + ); + await windowManager.setSize(const Size(800, 900)); + await _capture('inspector-$suffix-narrow-overlay'); + _check( + ref.read(appSettingsControllerProvider).elementPropertiesWidth == 390, + 'Narrow layout preserves preferred inspector width', + ); + await windowManager.setSize(const Size(1400, 960)); + await _native('chord', 'ctrl+shift+3'); + await _pause(); + final reading = + _elements((w) => w is BusyMarkWritersideProperties).single.widget + as BusyMarkWritersideProperties; + _check( + reading.readOnly, + '$suffix Reading retains validated read-only properties', + ); + await _capture('inspector-$suffix-reading'); + _check( + _source == initial, + '$suffix navigation and Reading leave source unchanged', + ); + await File( + p.join(widget.output.path, 'inspector.$suffix'), + ).writeAsString(_source); + await _native('chord', 'ctrl+shift+1'); + } + BusyMarkWritersideProperties inspector() => + _elements((w) => w is BusyMarkWritersideProperties).single.widget + as BusyMarkWritersideProperties; + BusyMarkComboRow combo(String title) => + _elements( + (w) => w is BusyMarkComboRow && w.title == title, + ).single.widget + as BusyMarkComboRow; + Future chooseCombo(String title, String label) async { + await _tap( + _elements( + (w) => w is BusyMarkComboRow && w.title == title, + ).single, + ); + _nativeMenuOpen = true; + await _tapLabel(label); + } + + Future propertyText(String label, String value) async { + await _expandInspectorSection( + 'Advanced', + label, + (w) => w is BusyMarkGroupedTextEntry && w.label == label, + ); + final entry = _inspectorProperty(label); + await _type( + _elements( + (w) => w is TextField && w.controller == entry.controller, + ).single, + value, + ); + await _native('chord', 'ctrl+s'); + await _until( + () => !ref.read(workspaceControllerProvider).activeBuffer!.isDirty, + 'Focused $label saved', + ); + } + + Future reopenSource() async { + final buffer = ref.read(workspaceControllerProvider).activeBuffer!; + _check(await controller.saveActive(), 'Correction saves to buffer'); + _check( + await controller.closeDocumentBuffer(buffer.id), + 'Correction closes document', + ); + await controller.openActiveFile(buffer.filePath!); + await _native('chord', 'ctrl+shift+2'); + await _until( + () => _elements((w) => w is BusyMarkSourceEditor).isNotEmpty, + 'Correction Source reopened', + ); + } + + Future sourceCaret(int offset) async { + if (offset < 0) { + throw StateError( + 'Missing authored occurrence in ${ref.read(workspaceControllerProvider).activeBuffer?.displayName}', + ); + } + ((_elements((w) => w is BusyMarkSourceEditor).single as StatefulElement) + .state + as BusyMarkSourceEditorState) + .scrollToOffset(offset); + await _pause(); + await _requestProperties(); + } + + Future showTopicNavigation() => _expandInspectorSection( + 'Topic navigation', + 'Navigation elements', + (w) => + w is BusyMarkComboRow && w.title == 'Navigation elements', + ); + + await controller.openActiveFile( + p.join(widget.root.path, 'topics/inspector.topic'), + ); + await _native('chord', 'ctrl+shift+2'); + await _pause(); + await _requestProperties(); + await _topicProperties(); + await _pause(); + await showTopicNavigation(); + for (final choice in [ + ('all', 'All titled elements'), + ('none', 'No navigation'), + ('default', 'Use default'), + ]) { + await chooseCombo('Navigation elements', choice.$2); + _check( + combo('Navigation elements').selected == choice.$1, + 'Navigation ${choice.$1} selected after native choice', + ); + await reopenSource(); + await sourceCaret(0); + await _pause(); + await _requestProperties(); + await _topicProperties(); + await _pause(); + await showTopicNavigation(); + _check( + combo('Navigation elements').selected == choice.$1, + 'Navigation ${choice.$1} survives save close reopen', + ); + } + await chooseCombo('Navigation elements', 'Custom navigation'); + await propertyText('Navigation elements (for)', 'chapter,def'); + await reopenSource(); + await sourceCaret(0); + await _pause(); + await _requestProperties(); + await _topicProperties(); + await _pause(); + await showTopicNavigation(); + _check( + combo('Navigation elements').selected == 'custom', + 'Custom navigation survives reopening', + ); + await _capture('inspector-navigation-reopened'); + await controller.openActiveFile( + p.join(widget.root.path, 'topics/inspector-mappings.topic'), + ); + await _native('chord', 'ctrl+shift+2'); + await _until( + () => _elements((w) => w is BusyMarkSourceEditor).isNotEmpty, + 'Mapping controls open in Source', + ); + final mappingsPath = ref + .read(workspaceControllerProvider) + .activeBuffer! + .filePath!; + Future mappingCaret(String markup) async { + final state = + (_elements((w) => w is BusyMarkSourceEditor).single + as StatefulElement) + .state + as BusyMarkSourceEditorState; + state.scrollToOffset(_source.indexOf(markup) + 2); + await _pause(); + await _requestProperties(); + } + + Future reopenMapping(String markup) async { + await _native('chord', 'ctrl+s'); + await _until( + () => !ref.read(workspaceControllerProvider).activeBuffer!.isDirty, + 'Mapping source saved', + ); + _check( + await controller.closeDocumentBuffer( + ref.read(workspaceControllerProvider).activeBuffer!.id, + ), + 'Mapping buffer closes', + ); + await controller.openActiveFile(mappingsPath); + await _native('chord', 'ctrl+shift+2'); + await _pause(); + await mappingCaret(markup); + await _pause(); + } + + await mappingCaret('Ctrl+S'), + 'Shortcut action and help instance survive reopen without replacing literal content', + ); + await _capture('inspector-shortcut-mapping'); + await mappingCaret('
    Guide'), + 'Link target edits href and retains formatted label', + ); + await _capture('inspector-link-target-mapping'); + await _native('chord', 'ctrl+shift+1'); + await _pause(); + await _tap(_field('Press Ctrl+S now.')); + final shortcutField = _field('Press Ctrl+S now.').widget as TextField; + shortcutField.controller!.selection = const TextSelection.collapsed( + offset: 8, + ); + await _pause(); + _check( + inspector().inlineTarget?.kind == BusyInlineKind.link, + 'Ordinary shortcut selection preserves the explicitly inspected link', + ); + await _requestProperties(); + _check( + inspector().inlineTarget?.kind == BusyInlineKind.writersideShortcut && _elements( (w) => - w is BusyMarkComboRow && w.values.contains('@topic'), - ).single, - ); - _nativeMenuOpen = true; - await _tapLabel('Topic Properties'); - _check(!_nativeMenuOpen, '$suffix GTK Topic Properties selected'); - } - - await topicProperties(); - _check( - _source == beforeLabel && - selectedField.controller!.selection == selection, - '$suffix opening Topic Properties preserves source and selection', + w is BusyMarkGroupedTextEntry && w.label == 'Action ID (key)', + ).isNotEmpty, + 'Editor exposes the same shortcut action control', + ); + await _capture('inspector-editor-shortcut-mapping'); + await controller.openActiveFile( + p.join(widget.root.path, 'topics/inspector-corrections.md'), + ); + await _native('chord', 'ctrl+shift+1'); + await _until( + () => _elements( + (w) => + w is TextField && w.controller?.text == 'Use {id="literal-code"}', + ).isNotEmpty, + 'Literal inline code Editor loaded', + ); + await _type(_field('Use {id="literal-code"}'), 'Try {id="literal-code"}'); + for (final text in [ + 'See {id="linked-literal"}', + 'Show {id="image-literal"}', + ]) { + await _type(_field(text), text.replaceFirst(RegExp(r'^\w+'), 'Read')); + } + await _native('chord', 'ctrl+s'); + await reopenSource(); + _check( + _source.contains('Try `{id="literal-code"}`'), + 'Ordinary content edit save reopen preserves inline code', + ); + await sourceCaret(_source.indexOf('Try')); + await _pause(); + _check( + inspector().target!.attributes['id'] == null, + 'Literal code is not a paragraph ID', + ); + await _capture('inspector-inline-ownership-reopened'); + for (final reference in [ + ( + identity: 'code-reference', + kind: BusyInlineKind.link, + literal: 'linked-literal', + ), + ( + identity: 'alt-reference', + kind: BusyInlineKind.image, + literal: 'image-literal', + ), + ]) { + await sourceCaret( + _source.indexOf( + 'Read ${reference.kind == BusyInlineKind.link ? '[' : '!['}', + ), ); - BusyMarkGroupedTextEntry labelEntry() => - _elements( - (w) => - w is BusyMarkGroupedTextEntry && - w.label == 'Switcher label', - ).single.widget - as BusyMarkGroupedTextEntry; - final labelField = labelEntry(); - labelField.controller!.text = switcherLabel; - labelField.focusNode!.requestFocus(); await _pause(); - _check( - _source == beforeLabel, - '$suffix switcher label draft is source-neutral', - ); - await _key(PhysicalKeyboardKey.enter, LogicalKeyboardKey.enter); - _check( - _source != beforeLabel && _source.contains('switcher-label'), - '$suffix switcher label property is authored', - ); - await _tap(_field('Section')); - final before = _source; - await _capture('authoring-$suffix-light'); - await _tap( - _elements( - (w) => w is BusyMarkSwitchRow && w.title == 'Collapsible', - ).single, - ); - _check( - _source != before && _source.contains('collapsible="true"'), - '$suffix ordinary section exposes collapse properties', - ); - final authored = _source; - await _tap( - _elements( - (w) => - w is BusyMarkHeaderIconButton && - w.tooltip == 'Expand Chapter' || - w is IconButton && w.tooltip == 'Expand Section', - ).single, + final target = inspector().target!; + final inline = target.inlines.singleWhere( + (i) => i.kind == reference.kind, ); _check( - _source == authored, - '$suffix local disclosure is source-neutral', - ); - _check(await controller.saveActive(), '$suffix save succeeds'); - stdout.writeln( - 'Saved $suffix: dirty=${ref.read(workspaceControllerProvider).activeBuffer?.isDirty}; ' - 'revision=${ref.read(workspaceControllerProvider).activeBuffer?.revision}', + target.attributes['id'] == null && + inline.attributes['id'] == reference.identity, + 'Reopened ${reference.identity} belongs to its inline reference, not paragraph', ); - controller.updateActiveEditorMode(DocumentViewModePreference.source); - await settings.setDocumentViewMode(DocumentViewModePreference.source); - await _pause(); _check( - _elements((w) => w is BusyMarkWysiwygEditor).isEmpty, - '$suffix Source switch', + reference.kind == BusyInlineKind.link + ? inline.children.single.kind == BusyInlineKind.code && + inline.children.single.text == '{id="${reference.literal}"}' + : inline.text == '{id="${reference.literal}"}' && + inline.attributes['width'] == '24', + 'Ordinary content edit save reopen retains ${reference.identity} literal and formatting', ); - controller.updateActiveEditorMode(DocumentViewModePreference.editor); - await settings.setDocumentViewMode(DocumentViewModePreference.editor); + } + await _capture('inspector-linked-code-reopened'); + await sourceCaret(_source.indexOf('Use [') + 1); + await _pause(); + _check( + inspector().target!.attributes['id'] == null && + inspector().target!.inlines.any( + (i) => i.attributes['id'] == 'owned-link', + ), + 'Trailing link attributes belong only to the link', + ); + await sourceCaret(_source.indexOf('- Second')); + await _pause(); + final beforeItemUndo = ref + .read(workspaceControllerProvider) + .activeBuffer! + .editorState + .undoState + .undo + .length; + await propertyText('Element anchor (id)', 'saved-item'); + _check( + inspector().target!.attributes['id'] == 'saved-item' && + inspector().path.any((b) => b.attributes['element'] == 'list'), + 'Native item conversion retains inspected item and ancestry', + ); + _check( + ref + .read(workspaceControllerProvider) + .activeBuffer! + .editorState + .undoState + .undo + .length == + beforeItemUndo + 1, + 'Native item property creates exactly one undo operation', + ); + _check( + controller.undoActiveBuffer(), + 'Native item conversion undo succeeds', + ); + await _pause(); + _check( + _source.contains('- Second item') && !_source.contains(' A')); + await _pause(); + await propertyText('Element anchor (id)', 'saved-note'); + await reopenSource(); + await sourceCaret(_source.indexOf('> A')); + await _pause(); + _check( + inspector().target!.attributes['id'] == 'saved-note' && + inspector().target!.attributes['style'] == 'note', + 'Native admonition ID and style survive save close reopen', + ); + _check( + _source.contains('> A **note**.\n\n{'), + 'Admonition attributes use the builder-verified blank-line placement', + ); + await _capture('inspector-admonition-reopened'); + for (final numbered in [ + (text: 'Numbered parent', identity: 'saved-numbered-item', start: '10'), + (text: 'Marker parent', identity: 'saved-marker-item', start: '3'), + ]) { + await sourceCaret(_source.indexOf('3. ${numbered.text}')); await _pause(); - _check( - _source == authored, - '$suffix Source to Editor preserves source', - ); - // Use the queued buffer transaction used by the application's tab - // actions; background validation may refresh buffer metadata here. - final closingBuffer = ref + final original = _source; + final undoCount = ref .read(workspaceControllerProvider) - .activeBuffer!; - final closed = await controller.closeDocumentBuffer(closingBuffer.id); - stdout.writeln( - 'Close $suffix: result=$closed; dirty=${closingBuffer.isDirty}; ' - 'revision=${closingBuffer.revision}; ' - 'content saved=${closingBuffer.text == closingBuffer.lastSavedText}; ' - 'current dirty=${ref.read(workspaceControllerProvider).activeBuffer?.isDirty}; ' - 'current document=${ref.read(workspaceControllerProvider).activeBuffer?.displayName}; ' - 'buffer remains=${ref.read(workspaceControllerProvider).documentBuffers.any((b) => b.id == closingBuffer.id)}; ' - 'message=${ref.read(workspaceControllerProvider).message}', - ); - _check(closed, '$suffix saved buffer closes'); - await controller.openActiveFile( - p.join(widget.root.path, 'topics/authoring.$suffix'), - ); - await _until( - () => - _elements((w) => w is BusyMarkWysiwygEditor).isNotEmpty && - _source == authored, - '$suffix saved topic reopens', + .activeBuffer! + .editorState + .undoState + .undo + .length; + await propertyText('Element anchor (id)', numbered.identity); + final converted = _source; + _check( + ref + .read(workspaceControllerProvider) + .activeBuffer! + .editorState + .undoState + .undo + .length == + undoCount + 1, + '${numbered.identity} conversion creates one undo operation', ); _check( - _source == authored, - '$suffix reopening from disk preserves authored semantics', + controller.undoActiveBuffer(), + '${numbered.identity} undo succeeds', ); - final variableField = _field('Textproduct'); - await _tap(variableField); - (variableField.widget as TextField).controller!.selection = - const TextSelection.collapsed(offset: 6); await _pause(); _check( - _elements( - (w) => - w is BusyMarkGroupedTextEntry && - w.label == 'Variable Reference', - ).isNotEmpty, - '$suffix variable reference properties survive reopening', + _source == original, + '${numbered.identity} undo restores original markers and authored override', ); - await topicProperties(); _check( - labelEntry().controller!.text == switcherLabel, - '$suffix switcher label survives save and Source to Editor', + controller.redoActiveBuffer(), + '${numbered.identity} redo succeeds', ); - await _tap(_field('Section')); + await _pause(); _check( - !(_elements((w) => w is BusyMarkWritersideProperties).single.widget - as BusyMarkWritersideProperties) - .topicSelected, - '$suffix returning to the same content retargets properties', + _source == converted, + '${numbered.identity} redo restores converted source', ); - await settings.setThemeModePreference(BusyMarkThemeModePreference.dark); - await settings.setEditorToolbarDirection( - EditorToolbarDirection.vertical, + await reopenSource(); + await sourceCaret(_source.indexOf('
  • b.attributes['element'] == 'list', ); - await windowManager.setSize(const Size(800, 900)); - await _capture('authoring-$suffix-dark-narrow-vertical'); - await settings.setLocaleTag('ar'); - await _capture('authoring-$suffix-rtl'); - await _captureScaled('authoring-$suffix-rtl-scaled'); - await settings.setLocaleTag('en'); - await settings.setThemeModePreference( - BusyMarkThemeModePreference.light, + final nested = target.children.singleWhere( + (b) => b.attributes['element'] == 'list', ); - await settings.setEditorToolbarDirection( - EditorToolbarDirection.horizontal, + _check( + target.attributes['id'] == numbered.identity && + list.attributes['start'] == numbered.start && + nested.attributes['start'] == '4', + '${numbered.identity} save close reopen preserves outer ${numbered.start} and nested 4', ); - await windowManager.setSize(const Size(1400, 960)); } + await _capture('inspector-numbered-list-reopened'); + await File( + p.join(widget.output.path, 'inspector-corrections.md'), + ).writeAsString(_source); + await controller.openPath('${widget.root.path}-defaults'); + await controller.openActiveFile( + p.join('${widget.root.path}-defaults', 'topics/properties.topic'), + ); + await _native('chord', 'ctrl+shift+2'); + await _until( + () => _elements((w) => w is BusyMarkSourceEditor).isNotEmpty, + 'Module-default Source document loaded', + ); + final defaultsSource = _source; + final defaultsSourceState = + (_elements((w) => w is BusyMarkSourceEditor).single + as StatefulElement) + .state + as BusyMarkSourceEditorState; + defaultsSourceState.scrollToOffset( + _source.indexOf(' defaultCombo(String title) => + _elements( + (w) => w is BusyMarkComboRow && w.title == title, + ).single.widget + as BusyMarkComboRow; + _check( + defaultCombo('Collapsible (collapsible)').labelFor('') == + 'Use default' && + defaultCombo( + 'Published initial state (default-state)', + ).labelFor('') == + 'Use default', + 'Inspector resolves omitted code properties from module defaults', + ); + _check( + _source == defaultsSource && + !ref.read(workspaceControllerProvider).activeBuffer!.isDirty, + 'Opening effective defaults is source-neutral', + ); + _check( + _elements( + (w) => w is Text && w.data == 'Default: Enabled · Module', + ).isNotEmpty && + _elements( + (w) => w is Text && w.data == 'Default: Expanded · Module', + ).isNotEmpty, + 'Compact default values appear beneath selectors', + ); + _check( + defaultCombo('Collapsible (collapsible)').tooltip == + 'Effective: Enabled — module default' && + _elements( + (w) => + w is Semantics && + w.properties.hint == 'Effective: Enabled — module default', + ).isNotEmpty, + 'Default controls retain complete provenance in help and accessibility', + ); + await _capture('inspector-module-defaults'); + await _tap( + _elements( + (w) => + w is BusyMarkComboRow && + w.title == 'Collapsible (collapsible)', + ).single, + ); + _nativeMenuOpen = true; + await _tapLabel('Disabled'); + await _native('chord', 'ctrl+s'); + await _pause(); + _check( + _source + .substring( + _source.indexOf('id="code-block-absent"'), + _source.indexOf('>', _source.indexOf('id="code-block-absent"')), + ) + .contains('collapsible="false"'), + 'Explicit false overrides the module collapse default', + ); + await _capture('inspector-module-override'); + await _tap( + _elements( + (w) => + w is BusyMarkComboRow && + w.title == 'Collapsible (collapsible)', + ).single, + ); + _nativeMenuOpen = true; + await _tapLabel('Use default'); + _check( + _source == defaultsSource, + 'Reset removes local override and restores the module default', + ); + await sourceCaret(_source.indexOf(' w is GestureDetector).firstWhere((e) { + Element? ancestor = e; + while (ancestor != null) { + if (ancestor.widget.runtimeType.toString() == + '_ElementPropertiesResizeHandle') { + return true; + } + Element? parent; + ancestor.visitAncestorElements((e) { + parent = e; + return false; + }); + ancestor = parent; + } + return false; + }); + final preferredWidth = ref + .read(appSettingsControllerProvider) + .elementPropertiesWidth; + Focus.of(resize).requestFocus(); + await _pause(); + await _native('chord', 'Left'); + _check( + ref.read(appSettingsControllerProvider).elementPropertiesWidth == + preferredWidth + 20, + 'Native keyboard increases inspector width', + ); + await _native('chord', 'Right'); + _check( + ref.read(appSettingsControllerProvider).elementPropertiesWidth == + preferredWidth, + 'Native keyboard restores preferred inspector width', + ); + await sourceCaret(_source.indexOf(' w is TextField && w.controller?.text == 'code-block-absent', + ).single, + ); + await _native('chord', 'Tab'); + _check( + FocusManager.instance.primaryFocus?.context + ?.findAncestorWidgetOfExactType< + BusyMarkWritersideProperties + >() != + null, + 'Native Tab traverses inspector controls', + ); + await _tap( + _elements( + (w) => w is BusyMarkHeaderIconButton && w.tooltip == 'Close', + ).singleWhere( + (e) => + e.findAncestorWidgetOfExactType() != + null, + ), + ); + await _until( + () => _elements((w) => w is BusyMarkWritersideProperties).isEmpty, + 'Inspector closes without pending drafts', + ); + _check( + FocusManager.instance.primaryFocus?.context + ?.findAncestorWidgetOfExactType() != + null, + 'Closing inspector restores Source editor focus', + ); + await ref + .read(appSettingsControllerProvider.notifier) + .setElementPropertiesVisible(true); + await controller.openPath(widget.root.path); await controller.openActiveFile(p.join(widget.root.path, 'README.md')); + await _native('chord', 'ctrl+shift+1'); await _until( () => _elements((w) => w is BusyMarkWysiwygEditor).isNotEmpty && @@ -703,11 +5132,15 @@ class _HarnessState extends ConsumerState<_Harness> { ); _check( _elements( - (w) => w.key == const ValueKey('wysiwyg-writerside-insert'), + (w) => w.key == const ValueKey('wysiwyg-writerside'), ).isEmpty && _elements((w) => w is BusyMarkWritersideProperties).isEmpty, 'Ordinary Markdown has no Writerside controls or properties', ); + _check( + ref.read(appSettingsControllerProvider).elementPropertiesVisible, + 'Inapplicable document preserves inspector visibility preference', + ); await _type(_field('Plain text'), 'Edited ordinary text'); _check( _source.contains('# Ordinary Markdown') && @@ -723,7 +5156,7 @@ class _HarnessState extends ConsumerState<_Harness> { ); await File(p.join(widget.output.path, 'generated.md')).writeAsString( await File( - p.join(widget.root.path, 'topics/authoring.md'), + p.join(widget.root.path, 'topics/authoring-markdown.md'), ).readAsString(), ); } on Object catch (error, stack) { @@ -731,6 +5164,19 @@ class _HarnessState extends ConsumerState<_Harness> { stderr.writeln('$error\n$stack'); await _capture('failure'); } + await _finish(failure); + } + + Future _finish(Object? failure) async { + if (Platform.environment['BUSYMARK_NATIVE_PROBE'] == '1') { + if (_accessibilitySessionExit != null) { + failure ??= StateError('Native accessibility session exited early'); + } else if (failure == null) { + _checks.add( + 'Native accessibility session remains connected through all input', + ); + } + } await File(p.join(widget.output.path, 'result.json')).writeAsString( const JsonEncoder.withIndent(' ').convert({ 'fixture': widget.root.path, diff --git a/tools/fetch_visualization_web.sh b/tools/fetch_visualization_web.sh index 3240c4c4..dfb8e4cf 100755 --- a/tools/fetch_visualization_web.sh +++ b/tools/fetch_visualization_web.sh @@ -30,6 +30,9 @@ SOURCE_FINGERPRINT="$({ "${SOURCE_DIR}/package.json" \ "${SOURCE_DIR}/package-lock.json" \ "${SOURCE_DIR}/render_engines.js" \ + "${SOURCE_DIR}/mermaid_renderer.js" \ + "${SOURCE_DIR}/plantuml_renderer.js" \ + "${SOURCE_DIR}/openapi_renderer.js" \ "${SOURCE_DIR}/mathjax_renderer.js" \ "${SOURCE_DIR}/mermaid_math_disabled.js" \ "${SOURCE_DIR}/build_render_engines.js" \ @@ -44,6 +47,10 @@ VERSION_FINGERPRINT="mermaid=${MERMAID_VERSION};plantuml=${PLANTUML_VERSION};sca if [[ -f "${OUTPUT_DIR}/VERSION" ]] && [[ "$(<"${OUTPUT_DIR}/VERSION")" == "${VERSION_FINGERPRINT}" ]] && [[ -s "${OUTPUT_DIR}/render-engines.js" ]] && + [[ -s "${OUTPUT_DIR}/mermaid-engine.js" ]] && + [[ -s "${OUTPUT_DIR}/plantuml-engine.js" ]] && + [[ -s "${OUTPUT_DIR}/openapi-engine.js" ]] && + [[ -s "${OUTPUT_DIR}/math-engine.js" ]] && [[ -s "${OUTPUT_DIR}/scalar.js" ]] && [[ -s "${OUTPUT_DIR}/viz-global.js" ]] && [[ -s "${OUTPUT_DIR}/harness.html" ]] && @@ -69,6 +76,9 @@ TOOL_DIR="${TEMP_DIR}/tool" mkdir -p -- "${TOOL_DIR}" cp -- "${SOURCE_DIR}/package.json" "${SOURCE_DIR}/package-lock.json" "${TOOL_DIR}/" cp -- "${SOURCE_DIR}/render_engines.js" "${TOOL_DIR}/" +cp -- "${SOURCE_DIR}/mermaid_renderer.js" "${TOOL_DIR}/" +cp -- "${SOURCE_DIR}/plantuml_renderer.js" "${TOOL_DIR}/" +cp -- "${SOURCE_DIR}/openapi_renderer.js" "${TOOL_DIR}/" cp -- "${SOURCE_DIR}/mathjax_renderer.js" "${TOOL_DIR}/" cp -- "${SOURCE_DIR}/build_render_engines.js" "${TOOL_DIR}/" npm ci \ diff --git a/tools/linux_native_menu_probe.py b/tools/linux_native_menu_probe.py index 29939e52..6200af0c 100644 --- a/tools/linux_native_menu_probe.py +++ b/tools/linux_native_menu_probe.py @@ -10,6 +10,7 @@ import json import os import sys +import subprocess import time target_gdk_backend = os.environ.get("GDK_BACKEND") @@ -23,7 +24,7 @@ gi.require_version("Atspi", "2.0") gi.require_version("Gdk", "3.0") -from gi.repository import Atspi, Gdk # noqa: E402 +from gi.repository import Atspi, Gdk, GLib # noqa: E402 def app_for_pid(pid): @@ -83,12 +84,47 @@ def main(): raise RuntimeError("The Wayland probe requires an isolated enclosing X display") pid, command, *args = sys.argv[1:] app = app_for_pid(int(pid)) - if command == "capture": + if command == "hold": + # Keep a client connected for the fixture's whole lifetime. GTK 3.24 + # can free its current key listener when the last short-lived AT-SPI + # client exits during dispatch (GNOME/gtk merge request 10391). + # Accessibility stays enabled, and individual commands still inspect + # and activate the real native controls independently. + loop = GLib.MainLoop() + listener = Atspi.EventListener.new(lambda *_: None) + if not listener.register("object:state-changed"): + raise RuntimeError("Could not retain the native accessibility session") + + def target_alive(): + try: + os.kill(int(pid), 0) + except ProcessLookupError: + loop.quit() + return GLib.SOURCE_REMOVE + return GLib.SOURCE_CONTINUE + + GLib.timeout_add(250, target_alive) + print("ready", flush=True) + loop.run() + listener.deregister("object:state-changed") + elif command == "capture": root = Gdk.get_default_root_window() capture = Gdk.pixbuf_get_from_window(root, 0, 0, root.get_width(), root.get_height()) capture.savev(args[0], "png", [], []) elif command == "key": key(args[0]) + elif command == "chord": + subprocess.run(["xdotool", "key", "--clearmodifiers", args[0]], check=True) + time.sleep(0.3) + elif command == "move-pointer": + subprocess.run(["xdotool", "mousemove", "--sync", str(int(args[0])), str(int(args[1]))], check=True) + time.sleep(0.3) + elif command == "click-pointer": + subprocess.run(["xdotool", "click", "--clearmodifiers", "1"], check=True) + time.sleep(0.3) + elif command == "type": + subprocess.run(["xdotool", "type", "--clearmodifiers", "--", args[0]], check=True) + time.sleep(0.3) elif command == "inspect": print(json.dumps([ {"name": node.get_name(), "role": node.get_role_name(), diff --git a/tools/nextcloud_notes_desktop_acceptance.dart b/tools/nextcloud_notes_desktop_acceptance.dart index d34f1714..3f102972 100644 --- a/tools/nextcloud_notes_desktop_acceptance.dart +++ b/tools/nextcloud_notes_desktop_acceptance.dart @@ -1,43 +1,41 @@ -import 'package:busymark/src/references/reference_controller.dart'; -import 'package:busymark/src/references/reference_ui.dart'; -import 'package:busymark/src/app/command_registry.dart'; -import 'package:busymark/src/app/busymark_design.dart'; -import 'package:busymark/src/nextcloud_notes/data/server_uri.dart'; -import 'package:busymark/src/editor/wysiwyg/wysiwyg_editor.dart'; -import 'package:flutter/gestures.dart'; -import 'package:busymark/src/nextcloud_notes/data/notes_store.dart'; -import 'package:busymark/src/app/busymark_dialogs.dart'; -import 'package:busymark/src/nextcloud_notes/presentation/notes_workspace_ui.dart'; -import 'package:busymark/src/nextcloud_notes/application/notes_transfer_service.dart'; -import 'package:busymark/src/app/quick_open.dart'; -import 'package:busymark/src/nextcloud_notes/application/notes_navigation.dart'; -import 'package:busymark/src/nextcloud_notes/application/notes_search_controller.dart'; -// Disposable native acceptance target. Production widgets/controllers, private -// profile, one explicitly trusted test CA, and only test-owned server resources. import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'dart:ui' as ui; -import 'package:busymark/src/app/app_settings.dart'; import 'package:busymark/src/app/app_router.dart'; +import 'package:busymark/src/app/app_settings.dart'; import 'package:busymark/src/app/busymark_app.dart'; +import 'package:busymark/src/app/busymark_design.dart'; +import 'package:busymark/src/app/busymark_dialogs.dart'; +import 'package:busymark/src/app/command_registry.dart'; +import 'package:busymark/src/app/quick_open.dart'; import 'package:busymark/src/app/startup_path.dart'; import 'package:busymark/src/app/system_accent.dart'; import 'package:busymark/src/editor/source/source_editor.dart'; +import 'package:busymark/src/editor/wysiwyg/wysiwyg_editor.dart'; import 'package:busymark/src/local_history/local_history_controller.dart'; import 'package:busymark/src/local_history/local_history_store.dart'; import 'package:busymark/src/nextcloud_notes/application/nextcloud_connection.dart'; +import 'package:busymark/src/nextcloud_notes/application/notes_navigation.dart'; import 'package:busymark/src/nextcloud_notes/application/notes_repository.dart'; +import 'package:busymark/src/nextcloud_notes/application/notes_search_controller.dart'; import 'package:busymark/src/nextcloud_notes/application/notes_settings_controller.dart'; +import 'package:busymark/src/nextcloud_notes/application/notes_transfer_service.dart'; import 'package:busymark/src/nextcloud_notes/data/notes_api_client.dart'; import 'package:busymark/src/nextcloud_notes/data/notes_capabilities.dart'; import 'package:busymark/src/nextcloud_notes/data/notes_secret_store.dart'; +import 'package:busymark/src/nextcloud_notes/data/notes_store.dart'; +import 'package:busymark/src/nextcloud_notes/data/server_uri.dart'; import 'package:busymark/src/nextcloud_notes/domain/notes_models.dart'; +import 'package:busymark/src/nextcloud_notes/presentation/notes_workspace_ui.dart'; import 'package:busymark/src/platform/linux_header_bar_service.dart'; +import 'package:busymark/src/references/reference_controller.dart'; +import 'package:busymark/src/references/reference_ui.dart'; import 'package:busymark/src/workspace/recovery_persistence.dart'; import 'package:busymark/src/workspace/session_persistence.dart'; import 'package:busymark/src/workspace/workspace_controller.dart'; +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; @@ -47,14 +45,63 @@ import 'package:http/io_client.dart'; import 'package:uuid/uuid.dart'; import 'package:window_manager/window_manager.dart'; +// Reusable native integration target. Production widgets/controllers, private +// profile, one explicitly trusted test CA, and only test-owned server resources. + +enum _Scenario { + full('full'), + workspace('workspace'), + offlineStart('offline-start'), + offlineResume('offline-resume'), + references('references'), + referencesResume('references-resume'), + localReferences('local-references'), + localReferencesResume('local-references-resume'), + writersideReferences('writerside-references'), + recoveryStart('recovery-start'), + recoveryResume('recovery-resume'), + performance('performance'); + + const _Scenario(this.option); + final String option; + + bool get reopensProfile => const { + offlineResume, + referencesResume, + localReferencesResume, + recoveryResume, + performance, + }.contains(this); + + bool get retainsResources => + const {offlineStart, references, recoveryStart}.contains(this); + + bool get endsOffline => const { + offlineStart, + recoveryStart, + referencesResume, + localReferencesResume, + performance, + }.contains(this); +} + Future main(List args) async { WidgetsFlutterBinding.ensureInitialized(); if (args.length < 4 || args.length > 5) { stderr.writeln( - 'Usage: PRIVATE_CREDENTIALS TEST_CA PRIVATE_OUTPUT DISPOSABLE_CONTAINER', + 'Usage: PRIVATE_CREDENTIALS TEST_CA PRIVATE_OUTPUT DISPOSABLE_CONTAINER ' + '[${_Scenario.values.map((s) => '--${s.option}').join('|')}]', ); exit(64); } + final option = args.length == 5 ? args[4] : '--full'; + final scenario = _Scenario.values + .where((s) => option == '--${s.option}') + .firstOrNull; + if (scenario == null) { + stderr.writeln('Unknown scenario: $option'); + exit(64); + } final credentials = jsonDecode(await File(args[0]).readAsString()) as Map; final output = Directory(args[2]); await output.create(recursive: true); @@ -64,28 +111,8 @@ Future main(List args) async { final server = Uri.parse(credentials['server'] as String); final login = credentials['loginName'] as String; final password = credentials['appPassword'] as String; - final performance = args.length == 5 && args[4] == '--performance-m2'; - final milestone3 = args.length == 5 && args[4] == '--m3'; - final local3 = - args.length == 5 && - ['--local-m3', '--restart-local-m3'].contains(args[4]); - final writerside3 = args.length == 5 && args[4] == '--writerside-m3'; - final restart3 = - args.length == 5 && - ['--restart-m3', '--restart-local-m3'].contains(args[4]); - final restarting = args.length == 5 && args[4] == '--restart-m2'; - final stopOffline = args.length == 5 && args[4] == '--stop-offline-m2'; - final correctionsStart = - args.length == 5 && args[4] == '--corrections-start-m2'; - final correctionsResume = - args.length == 5 && args[4] == '--corrections-resume-m2'; - final resumeOffline = args.length == 5 && args[4] == '--resume-offline-m2'; NextcloudAccount account; - if (restarting || - restart3 || - performance || - resumeOffline || - correctionsResume) { + if (scenario.reopensProfile) { final persisted = await NotesStore.open( path: '${output.path}/notes.sqlite3', ); @@ -155,17 +182,7 @@ Future main(List args) async { password: password, output: output, containerName: args[3], - restart: restarting, - milestone3: milestone3, - local3: local3, - writerside3: writerside3, - restart3: restart3, - stopOffline: stopOffline, - resumeOffline: resumeOffline, - correctionsStart: correctionsStart, - correctionsResume: correctionsResume, - performance: performance, - m2Only: args.length == 5, + scenario: scenario, ), ), ); @@ -289,31 +306,21 @@ class _Harness extends ConsumerStatefulWidget { required this.password, required this.output, required this.containerName, - this.restart = false, - this.milestone3 = false, - this.local3 = false, - this.writerside3 = false, - this.restart3 = false, - this.stopOffline = false, - this.resumeOffline = false, - this.correctionsStart = false, - this.correctionsResume = false, - this.performance = false, - this.m2Only = false, + required this.scenario, }); final NextcloudAccount account; final _Transport transport; final String password; final Directory output; final String containerName; - final bool milestone3, restart3, local3, writerside3; - final bool restart, - m2Only, - performance, - stopOffline, - resumeOffline, - correctionsStart, - correctionsResume; + final _Scenario scenario; + bool get resumeOffline => scenario == _Scenario.offlineResume; + bool get stopOffline => scenario == _Scenario.offlineStart; + bool get correctionsStart => scenario == _Scenario.recoveryStart; + bool get resumeReferences => const { + _Scenario.referencesResume, + _Scenario.localReferencesResume, + }.contains(scenario); @override ConsumerState<_Harness> createState() => _HarnessState(); } @@ -576,14 +583,14 @@ class _HarnessState extends ConsumerState<_Harness> { draft.accountId == widget.account.id && draft.hasPendingChanges && draft.content.contains('busymark-attachment:'), - 'm2ProcessRestartPendingIdentityAndText', + 'processRestartPendingIdentityAndText', ); final available = await repository.attachmentAvailability(draft.localId); check( available.required.isNotEmpty && available.available.length == available.required.length && available.external.isEmpty, - 'm2ProcessRestartPendingAttachmentBytes', + 'processRestartPendingAttachmentBytes', ); await workspace.openNextcloudNote(draft.localId); await wait( @@ -594,9 +601,34 @@ class _HarnessState extends ConsumerState<_Harness> { ); check( widget.transport.offline && widget.transport.writes == 0, - 'm2RestartBeforeAnyPublication', + 'workspaceRestartBeforeAnyPublication', + ); + final offline = await ref.read( + notesOfflineProvider(widget.account.id).future, + ); + check( + offline.categoryRetained('Workspace'), + 'processRestartCategoryRequirement', + ); + await ref.read(localHistoryControllerProvider.notifier).refresh(); + check( + ref.read(localHistoryControllerProvider).snapshot.documents.isNotEmpty, + 'processRestartRetainedHistory', ); - await capture('m2-pending-process-restart'); + for (final mode in DocumentViewModePreference.values) { + workspace.updateActiveEditorMode(mode); + await ref + .read(appSettingsControllerProvider.notifier) + .setDocumentViewMode(mode); + if (mode != DocumentViewModePreference.source) { + await wait( + () => widgets().any((i) => i.image is FileImage), + 'restart media ${mode.name}', + ); + } + await capture('workspace-restart-${mode.name}'); + } + await capture('workspace-pending-process-restart'); } else { await wait( () => widgets().any( @@ -612,14 +644,15 @@ class _HarnessState extends ConsumerState<_Harness> { widget.transport.offline = false; final categoryFixture = await repository.create( widget.account.id, - title: 'M2 category fixture', - category: 'M2/子/Sub', + title: 'Workspace category fixture', + category: 'Workspace/子/Sub', ); await repository.synchronize(widget.account.id); created.add(repository.noteById(categoryFixture.localId)!.serverId!); final navigation = NotesNavigationController() ..update(repository.notes, widget.account.id); - final categoryIndex = navigation.categoryPaths.indexOf('M2/子/Sub') + 4; + final categoryIndex = + navigation.categoryPaths.indexOf('Workspace/子/Sub') + 4; await Future.delayed(const Duration(milliseconds: 200)); void scrollCategory(Element element) { if (element is StatefulElement && element.state is ScrollableState) { @@ -666,8 +699,8 @@ class _HarnessState extends ConsumerState<_Harness> { .remoteNote! .localId; check( - repository.noteById(draftId)!.category == 'M2/子/Sub', - 'm2NativeNestedFilteredCreation', + repository.noteById(draftId)!.category == 'Workspace/子/Sub', + 'nativeNestedFilteredCreation', ); await wait( () => widgets().any( @@ -687,11 +720,11 @@ class _HarnessState extends ConsumerState<_Harness> { widgets().firstWhere( (f) => f.decoration?.labelText == 'Title', ), - 'M2 desktop café', + 'Workspace desktop café', ); await label('Save'); await wait( - () => repository.noteById(draftId)!.title == 'M2 desktop café', + () => repository.noteById(draftId)!.title == 'Workspace desktop café', 'reviewed title', ); await workspace.refreshNextcloudNotes(); @@ -710,7 +743,7 @@ class _HarnessState extends ConsumerState<_Harness> { workspace.updateActiveText( '${ref.read(workspaceControllerProvider).activeText}\n\n![image](${attachment.reference})\n\n', ); - check(await workspace.saveActive(), 'm2OfflineEditorMediaSave'); + check(await workspace.saveActive(), 'workspaceOfflineEditorMediaSave'); final freshStore = await NotesStore.open(path: repository.store.path); final persisted = (await freshStore.notes()).firstWhere( (n) => n.localId == draft.localId, @@ -718,11 +751,15 @@ class _HarnessState extends ConsumerState<_Harness> { check( persisted.content.contains(attachment.reference) && (await freshStore.attachments(draft.localId)).isNotEmpty, - 'm2RestartDurableTextAndMedia', + 'workspaceRestartDurableTextAndMedia', ); await freshStore.close(); - await capture('m2-sidebar'); + await capture('workspace-sidebar'); if (widget.stopOffline) { + final offline = await ref.read( + notesOfflineProvider(widget.account.id).future, + ); + await offline.setRequirement('category', 'Workspace'); await File('${widget.output.path}/offline-journey.json').writeAsString( jsonEncode({ 'localId': draft.localId, @@ -740,7 +777,7 @@ class _HarnessState extends ConsumerState<_Harness> { ); widgets() .firstWhere((f) => f.decoration?.labelText == 'Quick Open') - .onChanged!('M2 desktop'); + .onChanged!('Workspace desktop'); await wait( () => widgets().any( (t) => @@ -750,7 +787,7 @@ class _HarnessState extends ConsumerState<_Harness> { ), 'quick open results', ); - await capture('m2-quick-open'); + await capture('workspace-quick-open'); final quickTile = widgets().lastWhere( (t) => t.title is Text && @@ -762,12 +799,12 @@ class _HarnessState extends ConsumerState<_Harness> { check( ref.read(workspaceControllerProvider).activeBuffer?.remoteNote?.localId == draft.localId, - 'm2QuickOpenStableIdentity', + 'workspaceQuickOpenStableIdentity', ); final search = showNotesSearch( rootNavigatorKey.currentContext!, ref, - query: '"alpha beta" category:M2', + query: '"alpha beta" category:Workspace', ); await wait( () => widgets().isNotEmpty, @@ -782,7 +819,7 @@ class _HarnessState extends ConsumerState<_Harness> { ), 'indexed result', ); - await capture('m2-search'); + await capture('workspace-search'); final result = widgets().lastWhere( (t) => t.title is Text && @@ -794,12 +831,12 @@ class _HarnessState extends ConsumerState<_Harness> { check( ref.read(appSettingsControllerProvider).documentViewMode == DocumentViewModePreference.source, - 'm2ExplicitSourceLocation', + 'workspaceExplicitSourceLocation', ); final seed = await repository.create( widget.account.id, - title: 'M2 second', - category: 'M2/子/Sub', + title: 'Workspace second', + category: 'Workspace/子/Sub', content: 'alpha beta second', ); final batch = showNotesBatch(rootNavigatorKey.currentContext!, ref, [ @@ -814,18 +851,18 @@ class _HarnessState extends ConsumerState<_Harness> { ); widgets() .firstWhere((f) => f.decoration?.labelText == 'Category') - .onChanged!('M2/Moved/子'); - await capture('m2-batch-review'); + .onChanged!('Workspace/Moved/子'); + await capture('workspace-batch-review'); await label('Save'); await wait( () => - repository.noteById(draft.localId)!.category == 'M2/Moved/子' && - repository.noteById(seed.localId)!.category == 'M2/Moved/子', + repository.noteById(draft.localId)!.category == 'Workspace/Moved/子' && + repository.noteById(seed.localId)!.category == 'Workspace/Moved/子', 'batch durability', ); Navigator.of(rootNavigatorKey.currentContext!).pop(); await batch; - check(true, 'm2ReviewedBatchOrganize'); + check(true, 'workspaceReviewedBatchOrganize'); widget.transport.offline = false; await workspace.refreshNextcloudNotes(); for (final id in [draft.localId, seed.localId]) { @@ -834,11 +871,11 @@ class _HarnessState extends ConsumerState<_Harness> { final offline = await ref.read( notesOfflineProvider(widget.account.id).future, ); - await offline.setRequirement('category', 'M2'); + await offline.setRequirement('category', 'Workspace'); await offline.reconcile(); check( (await offline.inspect(repository.noteById(draft.localId)!)).available, - 'm2CategoryOfflineCompleteness', + 'workspaceCategoryOfflineCompleteness', ); final status = showNotesOffline( rootNavigatorKey.currentContext!, @@ -849,7 +886,7 @@ class _HarnessState extends ConsumerState<_Harness> { () => widgets().any((t) => t.data == 'Make available offline'), 'offline status dialog', ); - await capture('m2-offline-status'); + await capture('workspace-offline-status'); Navigator.of(rootNavigatorKey.currentContext!).pop(); await status; widget.transport.offline = true; @@ -866,7 +903,7 @@ class _HarnessState extends ConsumerState<_Harness> { ); } } - check(true, 'm2DisconnectReopenRetainedMediaAllViews'); + check(true, 'workspaceDisconnectReopenRetainedMediaAllViews'); widget.transport.offline = false; await workspace.deleteNextcloudNote(draft.localId); created.remove(repository.noteById(draft.localId)!.serverId); @@ -879,7 +916,7 @@ class _HarnessState extends ConsumerState<_Harness> { () => widgets().isNotEmpty, 'recovery preview', ); - await capture('m2-recovery'); + await capture('workspace-recovery'); await label('Recovery: New note'); await recover; final recoveredId = ref @@ -887,7 +924,7 @@ class _HarnessState extends ConsumerState<_Harness> { .activeBuffer! .remoteNote! .localId; - check(recoveredId != draft.localId, 'm2RecoveryNewIdentity'); + check(recoveredId != draft.localId, 'workspaceRecoveryNewIdentity'); await workspace.refreshNextcloudNotes(); created.add(repository.noteById(recoveredId)!.serverId!); final snapshot = await workspace.captureNextcloudExport({ @@ -907,7 +944,7 @@ class _HarnessState extends ConsumerState<_Harness> { cancellation: cancel, onProgress: progress, ); - check(exported.complete, 'm2PortableExportComplete'); + check(exported.complete, 'workspacePortableExportComplete'); return [exported.path]; }, ), @@ -920,7 +957,7 @@ class _HarnessState extends ConsumerState<_Harness> { () => widgets().isEmpty, 'export complete', ); - await capture('m2-export'); + await capture('workspace-export'); await label('Close'); await exportDialog; final exported = widget.output.listSync().whereType().firstWhere( @@ -937,15 +974,15 @@ class _HarnessState extends ConsumerState<_Harness> { () => widgets().isNotEmpty, 'import review', ); - await capture('m2-import-review'); + await capture('workspace-import-review'); await label('Import notes'); - check(await importReview == true, 'm2ImportReviewAccepted'); + check(await importReview == true, 'workspaceImportReviewAccepted'); final imported = await NotesTransferService( repository, ).importReviewed(review, widget.account.id, cancellation: cancellation); check( imported.every((i) => i.noteId != null), - 'm2ImportDurableDistinctIdentities', + 'workspaceImportDurableDistinctIdentities', ); await workspace.refreshNextcloudNotes(); for (final item in imported) { @@ -956,8 +993,11 @@ class _HarnessState extends ConsumerState<_Harness> { (i) => repository.noteById(i.noteId!)!.syncState == NoteSyncState.synced, ), - 'm2ImportedMediaSynced', + 'workspaceImportedMediaSynced', ); + if (widget.resumeOffline) { + await navigationJourney(workspace, repository.noteById(recoveredId)!); + } } Future treeAction( @@ -1007,7 +1047,7 @@ class _HarnessState extends ConsumerState<_Harness> { Future localReferenceJourney(WorkspaceController workspace) async { final root = Directory('${widget.output.path}/local'); - if (widget.restart3) { + if (widget.resumeReferences) { await workspace.openFolder(root.path); await workspace.openActiveFile('${root.path}/source.md'); workspace.updateActiveEditorMode(DocumentViewModePreference.preview); @@ -1025,8 +1065,8 @@ class _HarnessState extends ConsumerState<_Harness> { () => widgets().any((i) => i.image is FileImage), 'local media after process restart', ); - await capture('m3-local-process-restart'); - check(true, 'm3LocalLinksAndMediaAfterProcessRestart'); + await capture('references-local-process-restart'); + check(true, 'referencesLocalLinksAndMediaAfterProcessRestart'); return; } await root.create(recursive: true); @@ -1038,7 +1078,7 @@ class _HarnessState extends ConsumerState<_Harness> { ).writeAsString('[Hidden source](../old/target.md#actual-heading)\n'); await File('${root.path}/media.png').writeAsBytes(await png(Colors.green)); await File('${root.path}/old/target.md').writeAsString( - '# M3 local target\n\n## Actual heading\n\n![Media](../media.png)\n', + '# References local target\n\n## Actual heading\n\n![Media](../media.png)\n', ); await File( '${root.path}/source.md', @@ -1064,7 +1104,7 @@ class _HarnessState extends ConsumerState<_Harness> { ), 'local resolved outgoing references', ); - await capture('m3-local-references'); + await capture('references-local-references'); widgets() .firstWhere( (t) => @@ -1096,7 +1136,7 @@ class _HarnessState extends ConsumerState<_Harness> { ), 'exact destination review', ); - await capture('m3-refactoring-review'); + await capture('references-refactoring-review'); await label('Apply'); await wait( () => @@ -1112,7 +1152,7 @@ class _HarnessState extends ConsumerState<_Harness> { ), 'move link review', ); - await capture('m3-move-review'); + await capture('references-move-review'); await label('Apply'); await wait( () => @@ -1130,14 +1170,14 @@ class _HarnessState extends ConsumerState<_Harness> { (await File( '${root.path}/.drafts/hidden.md', ).readAsString()).contains('../new/renamed.md#actual-heading'), - 'm3OrdinaryRenameMoveIncomingOutgoingMedia', + 'referencesOrdinaryRenameMoveIncomingOutgoingMedia', ); for (final mode in DocumentViewModePreference.values) { workspace.updateActiveEditorMode(mode); await ref .read(appSettingsControllerProvider.notifier) .setDocumentViewMode(mode); - await capture('m3-local-${mode.name}'); + await capture('references-local-${mode.name}'); } await workspace.openFolder(root.path); await workspace.openActiveFile('${root.path}/source.md'); @@ -1146,7 +1186,7 @@ class _HarnessState extends ConsumerState<_Harness> { .read(workspaceControllerProvider) .activeText .contains('new/renamed.md#actual-heading'), - 'm3LocalWorkspaceReopenLinks', + 'referencesLocalWorkspaceReopenLinks', ); } @@ -1175,7 +1215,7 @@ class _HarnessState extends ConsumerState<_Harness> { (await File('${root.path}/topics/target.topic').readAsString()).contains( 'Element destination.', ), - 'm3WritersideXmlChooserSemanticLink', + 'referencesWritersideXmlChooserSemanticLink', ); await workspace.openActiveFile('${root.path}/topics/source.md'); await ref @@ -1194,7 +1234,7 @@ class _HarnessState extends ConsumerState<_Harness> { true, 'Writerside element references', ); - await capture('m3-writerside-references'); + await capture('references-writerside-references'); await nativeShortcut('ctrl+1'); await treeAction('topics', expand: true); await treeAction('target.topic', menu: 'Rename Topic File'); @@ -1214,7 +1254,7 @@ class _HarnessState extends ConsumerState<_Harness> { () => widgets().any((t) => t.data == 'Rename Preview'), 'established Writerside rename preview', ); - await capture('m3-writerside-refactoring'); + await capture('references-writerside-refactoring'); await label('Do Refactor'); await wait( () => @@ -1230,7 +1270,7 @@ class _HarnessState extends ConsumerState<_Harness> { source.contains('[target]: renamed.topic#actual-id') && source.contains('from="renamed.topic"') && source.contains('`[Code exclusion](missing.topic)`'), - 'm3WritersideEstablishedReviewedRenameAndSourcePreservation', + 'referencesWritersideEstablishedReviewedRenameAndSourcePreservation', ); await writersideContextJourney(workspace); } @@ -1317,7 +1357,7 @@ class _HarnessState extends ConsumerState<_Harness> { ), 'conditional include and cross-module ambiguity', ); - await capture('m3-writerside-guide-unavailable'); + await capture('references-writerside-guide-unavailable'); widgets() .firstWhere( (t) => @@ -1355,7 +1395,7 @@ class _HarnessState extends ConsumerState<_Harness> { ), 'beta include available in References', ); - await capture('m3-writerside-beta-include'); + await capture('references-writerside-beta-include'); widgets() .firstWhere( (t) => @@ -1391,8 +1431,8 @@ class _HarnessState extends ConsumerState<_Harness> { 'shared', 'shared module UI context', ); - await capture('m3-writerside-shared-module'); - check(true, 'm3CrossModuleElementIncludeAndInstanceUiContexts'); + await capture('references-writerside-shared-module'); + check(true, 'referencesCrossModuleElementIncludeAndInstanceUiContexts'); } Future chooseAuthoredLink( @@ -1437,7 +1477,7 @@ class _HarnessState extends ConsumerState<_Harness> { ), 'resolved target chooser row', ); - await capture('m3-target-chooser'); + await capture('references-target-chooser'); widgets() .firstWhere( (t) => @@ -1475,7 +1515,7 @@ class _HarnessState extends ConsumerState<_Harness> { } Future referenceJourney(WorkspaceController workspace) async { - if (widget.restart3) { + if (widget.resumeReferences) { final saved = jsonDecode( await File( @@ -1516,26 +1556,26 @@ class _HarnessState extends ConsumerState<_Harness> { .read(workspaceControllerProvider) .activeText .contains(saved['marker'] as String), - 'm3CachedOfflineLinkAfterProcessRestart', + 'referencesCachedOfflineLinkAfterProcessRestart', ); - await capture('m3-offline-restart'); + await capture('references-offline-restart'); if (browserUrl is String) { - check(true, 'm3ActualBrowserCopiedUrlInternalNavigation'); + check(true, 'referencesActualBrowserCopiedUrlInternalNavigation'); } return; } - final marker = 'M3 intended target ${const Uuid().v4()}'; + final marker = 'References intended target ${const Uuid().v4()}'; final target = await repository.create( widget.account.id, - title: 'M3 portable target', - category: 'M3/Original', - content: '# M3 target\n\n$marker\n\n## Heading\n', + title: 'References portable target', + category: 'References/Original', + content: '# References target\n\n$marker\n\n## Heading\n', ); final source = await repository.create( widget.account.id, - title: 'M3 portable source', - category: 'M3/Original', - content: '# M3 source\n\nSelected link\n', + title: 'References portable source', + category: 'References/Original', + content: '# References source\n\nSelected link\n', ); await repository.synchronize(widget.account.id); final targetTitle = repository.noteById(target.localId)!.title; @@ -1554,7 +1594,7 @@ class _HarnessState extends ConsumerState<_Harness> { await ref .read(appSettingsControllerProvider.notifier) .setDocumentViewMode(DocumentViewModePreference.source); - await chooseAuthoredLink('M3 portable target', href); + await chooseAuthoredLink('References portable target', href); await wait( () => ref .read(workspaceControllerProvider) @@ -1569,7 +1609,7 @@ class _HarnessState extends ConsumerState<_Harness> { ); check( stored.content.contains('[Selected link]($href)'), - 'm3ProductionChooserStoredPortableLink', + 'referencesProductionChooserStoredPortableLink', ); workspace.updateActiveEditorMode(DocumentViewModePreference.editor); await ref @@ -1600,7 +1640,7 @@ class _HarnessState extends ConsumerState<_Harness> { widgets().firstWhere( (f) => f.decoration?.labelText == 'Quick Open', ), - 'M3 portable target', + 'References portable target', ); await wait( () => widgets().any( @@ -1619,7 +1659,7 @@ class _HarnessState extends ConsumerState<_Harness> { ); check( ref.read(workspaceControllerProvider).activeText.contains(href), - 'm3EditorLinkChooserPreservesSelection', + 'referencesEditorLinkChooserPreservesSelection', ); await ref .read(busyMarkCommandRegistryProvider) @@ -1630,7 +1670,7 @@ class _HarnessState extends ConsumerState<_Harness> { widgets().any((t) => t.data == targetTitle), 'production References outgoing panel', ); - await capture('m3-references-outgoing'); + await capture('references-references-outgoing'); workspace.updateActiveEditorMode(DocumentViewModePreference.preview); await ref .read(appSettingsControllerProvider.notifier) @@ -1655,8 +1695,8 @@ class _HarnessState extends ConsumerState<_Harness> { ), 'incoming reference', ); - await capture('m3-references-incoming'); - check(true, 'm3RenderedLinkAndIncomingReferences'); + await capture('references-references-incoming'); + check(true, 'referencesRenderedLinkAndIncomingReferences'); await nativeShortcut('ctrl+1'); await wait( () => widgets().any( @@ -1675,20 +1715,20 @@ class _HarnessState extends ConsumerState<_Harness> { ); edit( widgets().firstWhere((f) => f.controller?.text == targetTitle), - 'M3 renamed target', + 'References renamed target', ); edit( widgets().firstWhere( (f) => f.decoration?.labelText == 'Category', ), - 'M3/Moved', + 'References/Moved', ); await label('Save'); await repository.synchronize(widget.account.id); check( repository.noteById(target.localId)!.serverId == targetId && - (await api.get(targetId)).category == 'M3/Moved', - 'm3TitleCategoryDoNotRetargetLink', + (await api.get(targetId)).category == 'References/Moved', + 'referencesTitleCategoryDoNotRetargetLink', ); await workspace.openNextcloudNote(source.localId); workspace.updateActiveText('${stored.content}\n[Browser pasted]($href)\n'); @@ -1709,7 +1749,7 @@ class _HarnessState extends ConsumerState<_Harness> { target.localId, 'pasted browser note URL navigation', ); - check(true, 'm3PastedBrowserRouteNavigatesInternally'); + check(true, 'referencesPastedBrowserRouteNavigatesInternally'); await File('${widget.output.path}/portable-browser.json').writeAsString( jsonEncode({ 'sourceLocalId': source.localId, @@ -1749,12 +1789,12 @@ class _HarnessState extends ConsumerState<_Harness> { (ref.read(workspaceControllerProvider).activeBuffer!.isDirty || (repository.noteById(source.localId)!.content == unsavedSource && repository.noteById(source.localId)!.hasPendingChanges)), - 'm3MissingTargetPreservesDirtySource', + 'referencesMissingTargetPreservesDirtySource', ); - await capture('m3-missing-target-failure'); + await capture('references-missing-target-failure'); workspace.updateActiveText(originalSource); widget.transport.offline = true; - await capture('m3-offline-ready'); + await capture('references-offline-ready'); } Future correctionSearch(String query, List counts) async { @@ -2238,14 +2278,14 @@ class _HarnessState extends ConsumerState<_Harness> { } Future performanceJourney(WorkspaceController workspace) async { - check(repository.notes.length == 10020, 'm2Real10000NoteLibrary'); + check(repository.notes.length >= 10000, 'largeNoteLibrary'); await wait( () => widgets().any( (t) => t.title is Text && (t.title as Text).data == 'All notes', ), 'large sidebar', ); - await capture('m2-10000-sidebar'); + await capture('workspace-10000-sidebar'); Future quickSample(String? screenshot) async { final watch = Stopwatch()..start(); final quick = showQuickOpen(rootNavigatorKey.currentContext!, ref); @@ -2253,7 +2293,9 @@ class _HarnessState extends ConsumerState<_Harness> { () => widgets().isNotEmpty && widgets().any( - (t) => t.title is Text && (t.title as Text).data == 'Duplicate 0', + (t) => + t.title is Text && + (t.title as Text).data == repository.notes.first.title, ), 'large quick open', ); @@ -2271,7 +2313,7 @@ class _HarnessState extends ConsumerState<_Harness> { final baselineQuick = []; for (var i = 0; i < 5; i++) { baselineQuick.add( - await quickSample(i == 0 ? 'm3-quick-open-baseline' : null), + await quickSample(i == 0 ? 'references-quick-open-baseline' : null), ); } ref.read(referencePanelRequestProvider.notifier).request(); @@ -2279,7 +2321,7 @@ class _HarnessState extends ConsumerState<_Harness> { final indexingQuick = []; for (var i = 0; i < 5; i++) { indexingQuick.add( - await quickSample(i == 0 ? 'm2-10000-quick-open' : null), + await quickSample(i == 0 ? 'workspace-10000-quick-open' : null), ); } final search = NotesSearchController(repository.store, widget.account.id); @@ -2292,12 +2334,12 @@ class _HarnessState extends ConsumerState<_Harness> { ); saved.stop(); await querying; - check(search.state.hits.isNotEmpty, 'm2LargeUiSearchDuringEdit'); + check(search.state.hits.isNotEmpty, 'workspaceLargeUiSearchDuringEdit'); await search.dispose(); final dialog = showNotesSearch( rootNavigatorKey.currentContext!, ref, - query: 'token42', + query: repository.notes.first.title, ); await wait( () => @@ -2305,7 +2347,7 @@ class _HarnessState extends ConsumerState<_Harness> { widgets().any((b) => b.tooltip == 'Source location'), 'large indexed search UI', ); - await capture('m2-10000-search'); + await capture('workspace-10000-search'); Navigator.of(rootNavigatorKey.currentContext!).pop(); await dialog; await File('${widget.output.path}/ui-performance.json').writeAsString( @@ -2320,37 +2362,11 @@ class _HarnessState extends ConsumerState<_Harness> { ); } - Future restartJourney(WorkspaceController workspace) async { - final note = repository.notes.firstWhere( - (n) => - n.title == 'M2 desktop café' && - n.syncState != NoteSyncState.deletedRemotely, - ); - final offline = await ref.read( - notesOfflineProvider(widget.account.id).future, - ); - check( - offline.categoryRetained('M2'), - 'm2ProcessRestartCategoryRequirement', - ); - check( - (await offline.inspect(note)).available, - 'm2ProcessRestartCachedBytes', - ); + Future navigationJourney( + WorkspaceController workspace, + NextcloudNote note, + ) async { await workspace.openNextcloudNote(note.localId); - for (final mode in DocumentViewModePreference.values) { - workspace.updateActiveEditorMode(mode); - await ref - .read(appSettingsControllerProvider.notifier) - .setDocumentViewMode(mode); - if (mode != DocumentViewModePreference.source) { - await wait( - () => widgets().any((i) => i.image is FileImage), - 'restart media ${mode.name}', - ); - } - await capture('m2-restart-${mode.name}'); - } await nativeShortcut('ctrl+p'); await wait(() => widgets().isNotEmpty, 'native Ctrl+P'); edit( @@ -2371,7 +2387,7 @@ class _HarnessState extends ConsumerState<_Harness> { widgets().single.documents, note.title, ).first.localId; - await capture('m2-restart-native-quick-open'); + await capture('workspace-restart-native-quick-open'); await nativeShortcut('enter'); await wait( () => @@ -2384,7 +2400,7 @@ class _HarnessState extends ConsumerState<_Harness> { selectedIdentity, 'native Quick Open Enter', ); - check(true, 'm2NativeCtrlPEnterStableIdentity'); + check(true, 'nativeCtrlPEnterStableIdentity'); await nativeShortcut('ctrl+p'); await wait( () => widgets().isNotEmpty, @@ -2392,7 +2408,7 @@ class _HarnessState extends ConsumerState<_Harness> { ); await nativeShortcut('escape'); await wait(() => widgets().isEmpty, 'Quick Open Escape'); - check(true, 'm2NativeQuickOpenEscape'); + check(true, 'nativeQuickOpenEscape'); await nativeShortcut('ctrl+shift+p'); await wait( () => @@ -2405,34 +2421,7 @@ class _HarnessState extends ConsumerState<_Harness> { () => !widgets().any((t) => t.data == 'Command Palette'), 'command palette Escape', ); - check(true, 'm2NativeCtrlShiftPCommandPalette'); - final search = showNotesSearch( - rootNavigatorKey.currentContext!, - ref, - query: '"alpha beta"', - ); - await wait( - () => widgets().isNotEmpty, - 'restart search dialog', - ); - await wait( - () => widgets().any( - (t) => - t.title is Text && - (t.title as Text).data == note.title && - t.trailing is IconButton, - ), - 'restart indexed results', - ); - await capture('m2-restart-search'); - Navigator.of(rootNavigatorKey.currentContext!).pop(); - await search; - check(true, 'm2ProcessRestartIndexedSearch'); - await ref.read(localHistoryControllerProvider.notifier).refresh(); - check( - ref.read(localHistoryControllerProvider).snapshot.documents.isNotEmpty, - 'm2ProcessRestartRetainedHistory', - ); + check(true, 'nativeCtrlShiftPCommandPalette'); // Local Markdown navigation/Quick Open remains available after changing workspace. final folder = await Directory( '${widget.output.path}/local-smoke', @@ -2455,9 +2444,12 @@ class _HarnessState extends ConsumerState<_Harness> { await quick; check( ref.read(workspaceControllerProvider).activeBuffer?.filePath == file.path, - 'm2LocalMarkdownQuickOpen', + 'workspaceLocalMarkdownQuickOpen', + ); + await localSearchSmoke( + 'local search smoke', + 'workspace-local-markdown-search', ); - await localSearchSmoke('local search smoke', 'm2-local-markdown-search'); final writerside = Directory('${widget.output.path}/writerside-smoke'); if (await writerside.exists()) await writerside.delete(recursive: true); await writerside.create(); @@ -2469,7 +2461,7 @@ class _HarnessState extends ConsumerState<_Harness> { check(copied.exitCode == 0, 'writersideFixtureCopy'); await workspace.openFolder(writerside.path); await workspace.openActiveFile('${writerside.path}/topics/intro.md'); - await localSearchSmoke('Introduction', 'm2-writerside-search'); + await localSearchSmoke('Introduction', 'workspace-writerside-search'); final writerQuick = showQuickOpen(rootNavigatorKey.currentContext!, ref); await wait( () => @@ -2488,7 +2480,7 @@ class _HarnessState extends ConsumerState<_Harness> { check( ref.read(workspaceControllerProvider).activeBuffer?.filePath == '${writerside.path}/topics/install.topic', - 'm2WritersideQuickOpen', + 'workspaceWritersideQuickOpen', ); } @@ -2558,33 +2550,34 @@ class _HarnessState extends ConsumerState<_Harness> { account: widget.account, appPassword: widget.password, ); - if (!widget.restart && - !widget.restart3 && - !widget.performance && - !widget.resumeOffline && - !widget.correctionsResume && - !widget.stopOffline) { + if (!widget.scenario.reopensProfile && !widget.stopOffline) { original = await api.getSettings(); } await ref.read(nextcloudConnectionProvider.notifier).reload(); - if (widget.restart || widget.m2Only || widget.performance) { + if (widget.scenario != _Scenario.full) { final workspace = ref.read(workspaceControllerProvider.notifier); - await workspace.openNextcloudWorkspace(widget.account.id); + check( + await workspace.openNextcloudWorkspace(widget.account.id), + 'openNativeWorkspace', + ); ref.read(appRouterProvider).go('/workspace'); - if (widget.local3) { - await localReferenceJourney(workspace); - } else if (widget.writerside3) { - await writersideReferenceJourney(workspace); - } else if (widget.milestone3 || widget.restart3) { - await referenceJourney(workspace); - } else if (widget.correctionsStart || widget.correctionsResume) { - await correctionJourney(workspace); - } else if (widget.performance) { - await performanceJourney(workspace); - } else if (widget.restart) { - await restartJourney(workspace); - } else { - await everydayJourney(workspace); + switch (widget.scenario) { + case _Scenario.localReferences || _Scenario.localReferencesResume: + await localReferenceJourney(workspace); + case _Scenario.writersideReferences: + await writersideReferenceJourney(workspace); + case _Scenario.references || _Scenario.referencesResume: + await referenceJourney(workspace); + case _Scenario.recoveryStart || _Scenario.recoveryResume: + await correctionJourney(workspace); + case _Scenario.performance: + await performanceJourney(workspace); + case _Scenario.workspace || + _Scenario.offlineStart || + _Scenario.offlineResume: + await everydayJourney(workspace); + case _Scenario.full: + throw StateError('Full scenario dispatched separately.'); } } else { final seed = await repository.create( @@ -3055,46 +3048,26 @@ class _HarnessState extends ConsumerState<_Harness> { } catch (error, stack) { failure = error; try { - await capture('m3-failure'); + await capture('${widget.scenario.option}-failure'); } catch (_) {} failureStack = stack; } finally { diagnostics.close(); - widget.transport.offline = - widget.restart || - widget.restart3 || - widget.performance || - widget.stopOffline || - widget.correctionsStart; + widget.transport.offline = widget.scenario.endsOffline; if (original != null) { try { await api.updateSettings(original!.toJson()); } catch (_) {} } - for (final id - in widget.stopOffline || widget.correctionsStart || widget.milestone3 - ? [] - : created) { - try { - await api.delete(id); - } catch (_) {} + if (!widget.scenario.retainsResources) { + for (final id in created) { + try { + await api.delete(id); + } catch (_) {} + } } await File( - '${widget.output.path}/${widget.milestone3 - ? 'report-m3' - : widget.restart3 - ? 'report-restart-m3' - : widget.correctionsStart - ? 'report-corrections-start' - : widget.correctionsResume - ? 'report-corrections-resume' - : widget.stopOffline - ? 'report-offline-start' - : widget.resumeOffline - ? 'report-offline-resume' - : widget.restart - ? 'report-restart' - : 'report'}.json', + '${widget.output.path}/report-${widget.scenario.option}.json', ).writeAsString( const JsonEncoder.withIndent(' ').convert({ 'passed': failure == null, diff --git a/tools/nextcloud_notes_live_acceptance.dart b/tools/nextcloud_notes_live_acceptance.dart index 7b67fb0e..06cab1d7 100644 --- a/tools/nextcloud_notes_live_acceptance.dart +++ b/tools/nextcloud_notes_live_acceptance.dart @@ -245,8 +245,8 @@ Future main(List arguments) async { originalSettings = await api(account).getSettings(); final linked = await repository.create( account.id, - title: 'M3 portable target', - category: 'M3/Scoped', + title: 'References portable target', + category: 'References/Scoped', content: '# Target\nconfirmed portable identity', ); await repository.synchronize(account.id); @@ -259,18 +259,24 @@ Future main(List arguments) async { if (conditional is! NotesNotModified) { throw StateError('Single-note conditional GET did not return 304.'); } - checks['m3ConditionalSingleNote200And304'] = true; + checks['referencesConditionalSingleNote200And304'] = true; final projected = await api(account).query( - NotesRetrievalScope(category: 'M3/Scoped', exclude: {'content', 'title'}), + NotesRetrievalScope( + category: 'References/Scoped', + exclude: {'content', 'title'}, + ), chunkSize: 1, ); if (!projected.notes.any((n) => n.id == target.serverId) || projected.notes.any((n) => n.has('content') || n.has('title'))) { throw StateError('Filtered/projected live response semantics differed.'); } - checks['m3FilteredProjectedRetrieval'] = true; + checks['referencesFilteredProjectedRetrieval'] = true; final unidentified = await api(account).query( - NotesRetrievalScope(category: 'M3/Scoped', exclude: {'id', 'content'}), + NotesRetrievalScope( + category: 'References/Scoped', + exclude: {'id', 'content'}, + ), chunkSize: 1, ); final singleProjection = await api(account).getProjection( @@ -283,7 +289,7 @@ Future main(List arguments) async { singleProjection.has('content')) { throw StateError('Excluded identifiers were not represented as omitted.'); } - checks['m3ExcludedIdentifiersAndSingleProjection'] = true; + checks['referencesExcludedIdentifiersAndSingleProjection'] = true; final checkpoint = repository.accountById(account.id)!.listEtag; await repository.save(target.localId, content: 'pending local edit'); final refreshed = await repository.refreshLinkedNote( @@ -295,7 +301,7 @@ Future main(List arguments) async { repository.accountById(account.id)!.listEtag != checkpoint) { throw StateError('304 changed pending local state.'); } - checks['m3ConditionalReadPreservesPendingAndCheckpoint'] = true; + checks['referencesConditionalReadPreservesPendingAndCheckpoint'] = true; final url = nextcloudNoteUrl( account.server, target.serverId!, @@ -303,7 +309,7 @@ Future main(List arguments) async { ); final source = await repository.create( account.id, - title: 'M3 link source', + title: 'References link source', content: '[portable target]($url)', ); await repository.synchronize(account.id); @@ -312,9 +318,9 @@ Future main(List arguments) async { if (!(await api(account).get(sourceId)).content.contains(url.toString())) { throw StateError('Portable link publication failed.'); } - checks['m3PortableUrlPublished'] = true; + checks['referencesPortableUrlPublished'] = true; - await _milestoneChecks( + await _protocolChecks( repository, connection, account, @@ -822,7 +828,7 @@ Future main(List arguments) async { } } -Future _milestoneChecks( +Future _protocolChecks( NotesRepository repository, _Connection connection, NextcloudAccount account, @@ -854,10 +860,10 @@ Future _milestoneChecks( checks['settingsPartialCustomSuffixNormalization'] = true; connection.loseNextSettingsResponse = true; final lost = await repository.changeSettings(account.id, settings, { - 'notesPath': 'BusyMark M1 isolated collection', + 'notesPath': 'BusyMark Protocol isolated collection', }); _require( - lost.notesPath == 'BusyMark M1 isolated collection' && + lost.notesPath == 'BusyMark Protocol isolated collection' && repository.accounts.single.settingsAttempt == null && repository.accounts.single.listEtag == null, 'Lost settings response did not reconcile by read/reset checkpoint.', @@ -882,7 +888,7 @@ Future _milestoneChecks( setClock(editTime); final draft = await repository.create( account.id, - title: 'M1 timestamp and filenames', + title: 'Protocol timestamp and filenames', category: 'BusyMarkAcceptance', content: '# Edit time', ); diff --git a/tools/test_web_render_host.sh b/tools/test_web_render_host.sh new file mode 100755 index 00000000..d22a0075 --- /dev/null +++ b/tools/test_web_render_host.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +set -euo pipefail +ulimit -c 0 +project_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +engine_root="$project_root/linux/flutter/ephemeral" +test -f "$engine_root/libflutter_linux_gtk.so" +assets="${BUSYMARK_VISUALIZATION_ASSETS:-$project_root/build/linux/x64/release/bundle/share/busymark/visualization}" +test -f "$assets/harness.html" +fixture_root="$(mktemp -d /tmp/bm-web-host.XXXXXX)" +trap 'rm -rf "$fixture_root"' EXIT +"${CXX:-clang++}" -std=c++17 -Wall -Wextra -Werror \ + "$project_root/tools/web_render_host_probe.cc" \ + -I"$engine_root" $(pkg-config --cflags --libs gtk+-3.0 webkit2gtk-4.1) \ + -L"$engine_root" -lflutter_linux_gtk -Wl,-rpath,"$engine_root" \ + -Wl,--wrap=fl_engine_get_binary_messenger \ + -Wl,--wrap=webkit_web_context_new_ephemeral \ + -Wl,--wrap=webkit_web_view_new_with_context \ + -Wl,--wrap=webkit_web_view_load_uri \ + -Wl,--wrap=g_file_get_contents \ + -o "$fixture_root/probe" +export BUSYMARK_VISUALIZATION_ASSETS="$assets" +export GSETTINGS_BACKEND=memory +export NO_AT_BRIDGE=1 +unset BUSYMARK_RELEASE_SMOKE +# Each case gets a fresh process and profile. Keep WebKit's sandbox enabled. +for operation in no-init pre-cancel renderMermaid renderPlantUml renderMathBatch \ + inspectOpenApi parseOpenApi rasterizeSvg openOpenApiReference concurrent-cancel \ + active-cancel engine-cancel snapshot-cancel engine-failure graphviz-failure failure load-failure harness-failure \ + shutdown-scheduled shutdown-loading shutdown-readiness shutdown-active \ + shutdown-channel shutdown-engine reference-shutdown recovery; do + profile="$fixture_root/$operation" + mkdir -p "$profile"/{home,config,cache,data,state,tmp} + HOME="$profile/home" XDG_CONFIG_HOME="$profile/config" \ + XDG_CACHE_HOME="$profile/cache" XDG_DATA_HOME="$profile/data" \ + XDG_STATE_HOME="$profile/state" TMPDIR="$profile/tmp" \ + timeout --signal=TERM 45s "$fixture_root/probe" "$operation" +done diff --git a/tools/validate_writerside_conformance.sh b/tools/validate_writerside_conformance.sh index b69539e6..76d9af11 100755 --- a/tools/validate_writerside_conformance.sh +++ b/tools/validate_writerside_conformance.sh @@ -27,6 +27,11 @@ if ! docker run --rm \ exit 1 fi +if [[ -n "${WRITERSIDE_CONFORMANCE_OUTPUT:-}" ]]; then + mkdir -p -- "$WRITERSIDE_CONFORMANCE_OUTPUT" + cp -a -- "$output_root/artifacts/." "$WRITERSIDE_CONFORMANCE_OUTPUT/" +fi + python3 - "$output_root/artifacts/report.json" <<'PYREPORT' import json, sys report = json.load(open(sys.argv[1])) @@ -40,19 +45,48 @@ if [[ "$source_root" == "test/fixtures/writerside/conformance_project" ]]; then test/fixtures/writerside/conformance_semantics.json --check fi -if [[ -n "${WRITERSIDE_CONFORMANCE_OUTPUT:-}" ]]; then - mkdir -p -- "$WRITERSIDE_CONFORMANCE_OUTPUT" - cp -a -- "$output_root/artifacts/." "$WRITERSIDE_CONFORMANCE_OUTPUT/" -fi if [[ "$source_root" == "test/fixtures/writerside/reference_conformance" ]]; then python3 tools/verify_writerside_reference_fixture.py \ "$output_root/artifacts/webHelpGUIDE2-all.zip" fi +if [[ "$source_root" == "test/fixtures/writerside/property_conformance" ]]; then + python3 tools/verify_writerside_property_fixture.py \ + "$output_root/artifacts/webHelpCONFORMANCE2-all.zip" +fi +if [[ "$source_root" == "test/fixtures/writerside/property_contract_conformance" ]]; then + python3 tools/verify_writerside_property_fixture.py \ + "$output_root/artifacts/webHelpCONFORMANCE2-all.zip" contract +fi +if [[ "$source_root" == "test/fixtures/writerside/list_capitalization_conformance" ]]; then + python3 tools/verify_writerside_property_fixture.py \ + "$output_root/artifacts/webHelpCONFORMANCE2-all.zip" list-caps + for variant in generic variable list-type list-start; do + variant_sources="$work_root/$variant" + variant_output="$work_root/$variant-output" + cp -a -- "$source_copy" "$variant_sources" + cp -- "$source_copy/variants/$variant.cfg" "$variant_sources/writerside.cfg" + if [[ -f "$source_copy/variants/$variant.tree" ]]; then + cp -- "$source_copy/variants/$variant.tree" "$variant_sources/conformance.tree" + fi + WRITERSIDE_CONFORMANCE_OUTPUT="$variant_output" bash tools/validate_writerside_conformance.sh "$variant_sources" Conformance/conformance + python3 tools/verify_writerside_property_fixture.py "$variant_output/webHelpCONFORMANCE2-all.zip" "list-caps-$variant" + done +fi +if [[ "$source_root" == "test/fixtures/writerside/property_defaults_conformance" ]]; then + python3 tools/verify_writerside_property_fixture.py \ + "$output_root/artifacts/webHelpCONFORMANCE2-all.zip" defaults +fi if [[ "$source_root" == "test/fixtures/writerside/conformance_project" ]]; then - if [[ -n "${WRITERSIDE_CONFORMANCE_OUTPUT:-}" ]]; then - export WRITERSIDE_CONFORMANCE_OUTPUT="$WRITERSIDE_CONFORMANCE_OUTPUT/reference-links" - fi - bash tools/validate_writerside_conformance.sh \ + evidence_root="${WRITERSIDE_CONFORMANCE_OUTPUT:-$work_root/evidence}" + WRITERSIDE_CONFORMANCE_OUTPUT="$evidence_root/reference-links" bash tools/validate_writerside_conformance.sh \ test/fixtures/writerside/reference_conformance ReferenceLinks/guide + WRITERSIDE_CONFORMANCE_OUTPUT="$evidence_root/properties" bash tools/validate_writerside_conformance.sh \ + test/fixtures/writerside/property_conformance Conformance/conformance + WRITERSIDE_CONFORMANCE_OUTPUT="$evidence_root/property-defaults" bash tools/validate_writerside_conformance.sh \ + test/fixtures/writerside/property_defaults_conformance Conformance/conformance + WRITERSIDE_CONFORMANCE_OUTPUT="$evidence_root/property-contract" bash tools/validate_writerside_conformance.sh \ + test/fixtures/writerside/property_contract_conformance Conformance/conformance + WRITERSIDE_CONFORMANCE_OUTPUT="$evidence_root/list-capitalization" bash tools/validate_writerside_conformance.sh \ + test/fixtures/writerside/list_capitalization_conformance Conformance/conformance fi diff --git a/tools/verify_writerside_property_fixture.py b/tools/verify_writerside_property_fixture.py new file mode 100644 index 00000000..88abecac --- /dev/null +++ b/tools/verify_writerside_property_fixture.py @@ -0,0 +1,347 @@ +"""Assert published behavior of the pinned property fixtures, not just exit status.""" +import sys +import zipfile +from html.parser import HTMLParser + + +class Elements(HTMLParser): + def __init__(self, source): + super().__init__() + self.stack = [] + self.elements = {} + self.texts = {} + self.images = {} + self.links = {} + self.descendants = {} + self.feed(source) + + def handle_starttag(self, tag, attrs): + attrs = dict(attrs) + for _, parent in self.stack: + if 'id' in parent: self.descendants.setdefault(parent['id'], []).append((tag, attrs)) + if 'id' in parent and tag == 'img': self.images.setdefault(parent['id'], []).append(attrs) + if 'id' in parent and tag == 'a': self.links.setdefault(parent['id'], []).append(attrs) + if 'id' in attrs: + self.elements[attrs['id']] = (attrs, list(self.stack)) + if tag not in {'meta', 'link', 'img', 'br', 'hr', 'input'}: + self.stack.append((tag, attrs)) + + def handle_endtag(self, tag): + for index in range(len(self.stack) - 1, -1, -1): + if self.stack[index][0] == tag: + del self.stack[index:] + break + + def handle_data(self, data): + for _, attrs in self.stack: + if 'id' in attrs: + self.texts[attrs['id']] = self.texts.get(attrs['id'], '') + data + + +def verify_literal_references(actual): + link = actual.elements['code-reference'][0] + assert link['href'].endswith(('properties.html', 'authoring.html')), link + assert actual.texts['code-reference'] == '{id="linked-literal"}' + assert any(tag == 'code' or 'code' in attrs.get('class', '').split() + for tag, attrs in actual.descendants['code-reference']) + image = actual.elements['alt-reference'][0] + assert image['alt'] == '{id="image-literal"}', image + assert image['src'].endswith('/logo.png') and image['width'] == '24', image + assert 'linked-literal' not in actual.elements and 'image-literal' not in actual.elements + + +def verify_numbered_item(actual, identity, outer_start, nested_start): + _, parents = actual.elements[identity] + lists = [attrs for tag, attrs in parents if tag == 'ol'] + assert lists[-1]['start'] == str(outer_start), (identity, lists) + nested = [attrs for tag, attrs in actual.descendants[identity] if tag == 'ol'] + assert len(nested) == 1 and nested[0]['start'] == str(nested_start), (identity, nested) + + +def verify_inspector_presentation(archive): + actual = Elements(archive.read('inspector-presentation.html').decode('utf-8')) + identity = 'diagnosed-cause-map' + assert actual.texts[identity] == '2. Diagnosed cause and source map' + collapse = [attrs for _, attrs in actual.elements[identity][1] + if attrs.get('class') == 'collapse__title'] + assert len(collapse) == 1, actual.elements[identity] + # The pinned builder omits data-expanded for its collapsed initial state. + assert collapse[0].get('data-expanded') in {None, 'false'}, collapse[0] + generated = 'long-generated-anchor-' + '-'.join(['documentation'] * 12) + assert generated in actual.elements, generated + print('Native presentation HTML: authored chapter anchor, published collapse and unchanged generated anchor verified') + + +def verify_explicit_inspection(archive, native): + page = Elements(archive.read('explicit-inspection.html').decode('utf-8')) + keys = [(attrs.get('data-key'), page.texts[identity]) + for identity, (attrs, _) in page.elements.items() + if attrs.get('data-key') in {'$Save', '$Copy'}] + assert keys == ([('$Save', 'Ctrl+S'), ('$Copy', 'Ctrl+C')] if native else + [('$Copy', 'Ctrl+C'), ('$Copy', 'Ctrl+C')]), keys + links = [attrs['href'] for identity, (attrs, _) in page.elements.items() + if page.texts.get(identity) == 'Read' and 'href' in attrs] + assert links == (['lists.html#title-case', 'lists.html#absent'] if native else + ['lists.html#absent', 'lists.html#absent']), links + if native: + for name in ['explicit-inspection.html', 'semantic-commands.html']: + actual = Elements(archive.read(name).decode('utf-8')) + for css, text in [('control', 'Open & Files'), + ('filepath', 'docs/example.txt'), + ('menupath', 'File | Open'), + ('keystroke', 'Ctrl+M')]: + matching = [attrs for identity, (attrs, _) in actual.elements.items() + if css in attrs.get('class', '').split() and + actual.texts.get(identity) == text] + assert len(matching) == 1, (name, css, matching) + if css == 'keystroke': + assert 'data-key' not in matching[0], (name, matching) + for style in ['tip', 'note', 'warning']: + matching = [identity for identity, (attrs, _) in actual.elements.items() + if attrs.get('data-type') == style and + actual.texts.get(identity, '').strip() == + style.title() + ' example.'] + assert len(matching) == 1, (name, style, matching) + quotes = [identity for identity, (attrs, _) in actual.elements.items() + if 'quote' in attrs.get('class', '').split() and + actual.texts.get(identity, '').strip() == 'Quote example.'] + assert len(quotes) == 1, (name, quotes) + print('Explicit inspection HTML: exact multiline reference owners' + + (', four semantic commands, admonitions and quote saved by the UI verified' + if native else ' verified')) + + +if len(sys.argv) > 2 and sys.argv[2].startswith('list-caps'): + mode = sys.argv[2] + with zipfile.ZipFile(sys.argv[1]) as archive: + if mode == 'list-caps-list-start': + page = Elements(archive.read('numbering.html').decode()) + for identity, start in [('native-seven','7'),('native-one','5'),('native-nine','9')]: + assert page.elements[identity][0]['start'] == start, (identity, page.elements[identity]) + else: + xml = Elements(archive.read('lists.html').decode()) + md = Elements(archive.read('native-lists.html').decode()) + original = 'a journey through HTTP and APIs' + title = 'A Journey Through HTTP And APIs' + expected = title if mode in {'list-caps-generic','list-caps-variable'} else original + assert xml.texts['absent'] == expected, xml.texts['absent'] + assert xml.texts['title-case'] == title + assert xml.texts['sentence-case'] == 'A journey through HTTP and APIs' + assert xml.texts['as-written'] == original + assert xml.texts['upper-case'] == 'A JOURNEY THROUGH HTTP AND APIS' + assert xml.texts['variable-case'] == title + identity = 'a-journey-through-http-and-apis' + assert md.texts[identity] == ('A JOURNEY THROUGH HTTP AND APIS' if mode == 'list-caps-native' else expected) + assert xml.texts['lists.topic'] == 'XML lists and capitalization' + toc = __import__('json').loads(archive.read('HelpTOC.json'))['entities']['pages'] + assert toc['lists']['title'] == 'XML lists and capitalization' + assert toc['native-lists']['title'] == 'Native lists and capitalization' + for page, letter_id, check_id, none_id in [(xml,'letters','checkbox','markerless'),(md,'native-letters','native-checkbox','native-markerless')]: + letter = page.elements[letter_id][0] + assert letter['type'] == 'a' and letter['start'] == '3' and letter['data-columns'] == '2',letter + assert page.texts[letter_id] == 'AlphaZebra' + assert page.elements[check_id][0]['class'] == 'list _checkbox' + check_items = [attrs for tag,attrs in page.descendants[check_id] if tag == 'li'] + assert len(check_items) == 2 + if page is xml: + assert check_items[0].get('checked') == 'true' + else: + # The pinned builder removes GFM checkbox prefixes but + # does not initialize checked state from them. XML checked + # attributes retain that separately verified capability. + assert all('checked' not in attrs for attrs in check_items) + assert page.elements[none_id][0]['class'] == 'list _none' + assert page.texts[none_id] == 'ZebraAlpha' + if mode == 'list-caps-native': + parent = md.elements['reviewed-list'][0] + assert parent['start'] == '10' and parent['data-columns'] == '2',parent + assert md.elements['reviewed-nested'][0]['start'] == '12' + assert md.texts['reviewed-list'].index('Sibling') < md.texts['reviewed-list'].index('Parent caption') + assert md.elements['embedded'][0]['start'] == '6' + if mode in {'list-caps', 'list-caps-native'}: + source = archive.read('nested-references.html').decode() + references = Elements(source) + for identity in ['link-one', 'link-two']: + assert references.elements[identity][0]['href'] == 'lists.html' + assert references.texts[identity] == 'Same' + # All parent, nested, quoted and XML variables remain distinct. + expected = 12 if mode == 'list-caps-native' else 13 + assert source.count('Resolved product') == expected + assert source.count('Resolved long product') == (1 if mode == 'list-caps-native' else 0) + assert '"$Copy"' in source and 'char-C' in source + verify_explicit_inspection(archive, mode == 'list-caps-native') + print('List/capitalization HTML: '+mode+' published markers, numbering, columns, sorting, checkbox controls and capitalization verified') + sys.exit(0) + +if len(sys.argv) > 2 and sys.argv[2] == 'native-defaults': + with zipfile.ZipFile(sys.argv[1]) as archive: + actual = Elements(archive.read('properties.html').decode('utf-8')) + layout = actual.elements['layout-default'][0] + assert layout['data-style'] == 'title-left', layout + assert layout['data-title-width'] == 'medium', layout + print('Native reset layout HTML: module Medium verified') + sys.exit(0) + +if len(sys.argv) > 2 and sys.argv[2] == 'native': + with zipfile.ZipFile(sys.argv[1]) as archive: + verify_inspector_presentation(archive) + for page in ['inspector.html', 'inspector-markdown.html']: + actual = Elements(archive.read(page).decode('utf-8')) + code = actual.elements['saved-code'][0] + assert code['class'] == 'code-block', (page, code) + assert code['data-emphasize-lines'] == '1', (page, code) + snippet = actual.texts['saved-code'] + assert 'second' in snippet and 'third' in snippet and 'first' not in snippet + if page == 'inspector.html': + assert any(a.get('data-expandable') == 'true' and + a.get('data-expanded') == 'true' + for a, _ in actual.elements.values()) + corrections = Elements(archive.read('inspector-corrections.html').decode('utf-8')) + assert 'literal-code' not in corrections.elements + assert any('{id="literal-code"}' in text and 'Try' in text for text in corrections.texts.values()) + item, parents = corrections.elements['saved-item'] + assert item['class'] == 'list__item' and any(tag == 'ul' for tag, _ in parents) + assert corrections.elements['saved-note'][0]['data-type'] == 'note' + task, parents = corrections.elements['saved-task'] + assert 'checked' not in task and any(a.get('class') == 'list _checkbox' for _, a in parents) + assert any(a.get('checked') == 'true' for a, _ in corrections.elements.values()) + verify_literal_references(corrections) + verify_numbered_item(corrections, 'saved-numbered-item', 10, 4) + verify_numbered_item(corrections, 'saved-marker-item', 3, 4) + print('Native saved HTML: source inclusion, snippet emphasis, inherited state, literal inline references, ordered starts, list-item and admonition IDs verified') + sys.exit(0) + +# The formal Topic/Chapter/Paragraph contract has an independent HTML oracle. +# In particular, accepted H1 attribute syntax is NOT evidence of publishing it. +if len(sys.argv) > 2 and sys.argv[2] in {'contract', 'contract-native'}: + import json, re + native = sys.argv[2] == 'contract-native' + with zipfile.ZipFile(sys.argv[1]) as archive: + if native: + verify_inspector_presentation(archive) + for xml in [True, False]: + stem = 'contract-xml' if xml else 'contract-markdown' + source = archive.read(stem + '.html').decode() + actual = Elements(source) + topic_text = actual.texts[stem + ('.topic' if xml else '.md')] + expected_topic = ('Edited XML topic' if xml else 'Edited Markdown topic') if native else ('XML default topic' if xml else 'Markdown topic heading') + if native and xml: + # Native Edit Title uses the existing instance child, not the default. + assert topic_text == 'Published ' + stem + ' instance', topic_text + elif xml: + assert topic_text == expected_topic, topic_text + else: + anchor = 'edited-topic-anchor' if native else 'authored-topic-anchor' + assert topic_text == expected_topic + ' {id="' + anchor + '"}', topic_text + assert anchor not in actual.elements + assert 'Published contract-markdown instance' not in source + chapter = ('edited-' if native else '') + ('xml-chapter' if xml else 'markdown-chapter') + assert actual.texts[chapter] == (('Edited XML chapter' if native else 'XML default chapter') if xml else ('Edited Markdown override' if native else 'Published Markdown override')) + nav_match = re.search(r'', source, re.S) + assert nav_match, stem + nav = json.loads(nav_match[1]) + ids = {item['id'] for item in nav} + prefix = 'xml' if xml else 'markdown' + assert {chapter, prefix + '-procedure', prefix + '-tab', prefix + '-term'} <= ids, ids + assert 'Literal %product%.' in source, stem + assert 'Excluded XML content.' not in source and 'Excluded Markdown content.' not in source, stem + label = stem + ' platform edited' if native else ('XML platform' if xml else 'Markdown platform') + assert 'data-sections-label="' + label + '"' in source, stem + assert 'data-section="One"' in source and 'data-section="Two"' in source, stem + if xml: + assert actual.elements['contract-sorting'][0]['data-sortable'] == 'true' + assert 'sticky-header' in actual.elements['contract-sorting'][0]['class'] + assert any(tag == 'th' and attrs.get('data-sortable') == 'false' for tag, attrs in actual.descendants['contract-sorting']) + print('Property contract HTML: default/instance titles, chapter override and anchor, four navigation target kinds, conditions, literal variables and switcher owners verified; H1 attribute limitation explicit') + sys.exit(0) + +defaults = len(sys.argv) > 2 and sys.argv[2] == 'defaults' +with zipfile.ZipFile(sys.argv[1]) as archive: + xml = Elements(archive.read('properties.html').decode()) + md = Elements(archive.read('properties-markdown.html').decode()) + for tag in ['chapter', 'procedure', 'code-block', 'deflist']: + for case in ['absent', 'false', 'true', 'expanded', 'collapsed', + 'false-expanded', 'false-collapsed', 'true-expanded', 'true-collapsed']: + identity = f'{tag}-{case}' + enabled = case.startswith('true') or defaults and not case.startswith('false') + expanded = case.endswith('expanded') or defaults and not case.endswith('collapsed') + attrs, parents = xml.elements[identity] + if tag in {'chapter', 'procedure'}: + collapse = [a for _, a in parents if a.get('class') == 'collapse__title'] + assert bool(collapse) == enabled, (identity, parents) + if enabled: + assert (collapse[0].get('data-expanded') == 'true') == expanded, identity + elif tag == 'code-block': + assert (attrs['class'] == 'code-collapse') == enabled, (identity, attrs) + if enabled: + assert (attrs['data-is-expanded'] == 'true') == expanded, identity + else: + item_suffixes = ['absent', 'inherited', 'collapsed', 'false'] if case.startswith('true-') else ['absent', 'inherited', 'expanded', 'collapsed'] + for item in item_suffixes: + item_id = f'{identity}-item-{item}' + # The initial fixture contains a spelling preserved in evidence. + if item_id not in xml.elements: + item_id = f'{identity}-item-item-{item}' + item_attrs, _ = xml.elements[item_id] + assert (item_attrs['data-expandable'] == 'true') == enabled, item_id + if enabled: + item_expanded = item == 'expanded' or item == 'inherited' and expanded or item in {'absent', 'false'} and not defaults and expanded + assert (item_attrs['data-expanded'] == 'true') == item_expanded, (item_id, item_attrs) + assert md.elements['md-code'][0]['class'] == 'code-block' + file_attrs = md.elements['md-file'][0] + assert (file_attrs['class'] == 'code-collapse') == defaults, file_attrs + assert file_attrs['data-emphasize-lines'] == '1', file_attrs + assert 'intro' in md.elements + snippet = md.texts['md-file'] + assert 'second' in snippet and 'third' in snippet and 'first' not in snippet, snippet + assert 'Install' in xml.texts['formatted-title'] and 'now' in xml.texts['formatted-title'], xml.texts['formatted-title'] + assert xml.texts['child-title-only'] == 'Child title only' + assert xml.images['image-xml'][0]['width'] == '48' and xml.images['image-xml'][0]['height'] == '24' + assert xml.links['image-xml'][0]['class'] == 'lightbox' + assert xml.images['image-paragraph'][0]['width'] == '16' + assert md.images['image-markdown'][0]['width'] == '48' + assert md.links['image-markdown'][0]['class'] == 'lightbox' + override = md.elements['source-override'][0] + assert override['src'] == '/other.png' and override['alt'] == 'Different' + assert override['width'] == '80' + assert md.elements['reference-override'][0]['href'] == 'properties.html#image-xml' + assert md.elements['empty-alt'][0]['alt'] == 'Logo' + assert xml.elements['empty-alt-xml'][0]['alt'] == 'Logo' + assert md.elements['lang-override'][0]['data-lang'] == 'none' + variable_chapter, variable_parents = xml.elements['chapter-variable'] + assert any(attrs.get('class') == 'collapse__title' and attrs.get('data-expanded') == 'true' + for _, attrs in variable_parents) + assert xml.elements['code-variable'][0]['class'] == 'code-collapse' + assert xml.elements['code-variable'][0]['data-is-expanded'] == 'true' + assert md.elements['embedded-code'][0]['class'] == 'code-block' + assert md.texts['embedded-code'] == 'embedded code' + layout = xml.elements['layout-default'][0] + assert layout['data-style'] == ('title-left' if defaults else 'title-top'), layout + if defaults: + assert layout['data-title-width'] == 'medium', layout + assert xml.elements['layout-wide'][0]['data-title-width'] == 'wide' + assert xml.elements['layout-full'][0]['data-style'] == 'title-top' + assert '{id="literal-code"}' in md.texts['paragraph-code-owner'] + assert '{id="literal-escaped"}' in md.texts['paragraph-escaped-owner'] + assert 'literal-code' not in md.elements and 'literal-escaped' not in md.elements + for identity, owner in [('owned-link', 'paragraph-link-owner'), ('owned-image', 'paragraph-image-owner')]: + assert any(attrs.get('id') == owner for _, attrs in md.elements[identity][1]), (identity, owner) + assert md.elements['native-list'][0]['class'] == 'list _bullet' + item, parents = md.elements['converted-list-item'] + assert item['class'] == 'list__item' and any(tag == 'ul' for tag, _ in parents) + assert md.elements['checked-native-list'][0]['class'] == 'list _checkbox' + assert md.elements['checked-xml-item'][0]['checked'] == 'true' + assert 'checked' not in md.elements['unchecked-xml-item'][0] + verify_literal_references(md) + verify_numbered_item(md, 'numbered-parent', 10, 4) + verify_numbered_item(md, 'marker-parent', 3, 4) + verify_numbered_item(md, 'nested-parent', 10, 7) + for identity, start, nested_start in [('native-numbered-list', 10, 4), + ('native-marker-list', 3, 4), + ('native-override-list', 10, 7)]: + assert md.elements[identity][0]['start'] == str(start), identity + nested = [attrs for tag, attrs in md.descendants[identity] if tag == 'ol'] + assert len(nested) == 1 and nested[0]['start'] == str(nested_start), (identity, nested) + note = md.elements['native-note'][0] + assert note['class'] == 'prompt' and note['data-type'] == 'note', note +print('Pinned property HTML: collapse, state, inheritance, module layout, literal inline ownership, ordered starts, list/item and admonition IDs, snippet emphasis, formatted titles and images verified') diff --git a/tools/visualization/build_render_engines.js b/tools/visualization/build_render_engines.js index 94e4196f..77da4b4a 100644 --- a/tools/visualization/build_render_engines.js +++ b/tools/visualization/build_render_engines.js @@ -123,8 +123,17 @@ const mermaidManifest = JSON.parse( ) await build({ - entryPoints: [entryPoint], - outfile, + entryPoints: { + 'render-engines': entryPoint, + 'mermaid-engine': path.join(path.dirname(entryPoint), 'mermaid_renderer.js'), + 'plantuml-engine': path.join(path.dirname(entryPoint), 'plantuml_renderer.js'), + 'openapi-engine': path.join(path.dirname(entryPoint), 'openapi_renderer.js'), + 'math-engine': path.join(path.dirname(entryPoint), 'mathjax_renderer.js'), + }, + outdir: path.dirname(outfile), + // Fixed names keep the private resource allowlist exact, without admitting + // arbitrary paths or build-generated chunk names. No runtime dependency. + external: ['./mermaid-engine.js', './plantuml-engine.js', './openapi-engine.js', './math-engine.js'], bundle: true, format: 'esm', platform: 'browser', diff --git a/tools/visualization/harness.html b/tools/visualization/harness.html index 797299ea..84a0333c 100644 --- a/tools/visualization/harness.html +++ b/tools/visualization/harness.html @@ -5,7 +5,6 @@ -
    diff --git a/tools/visualization/mathjax_renderer.js b/tools/visualization/mathjax_renderer.js index 40935081..445a3895 100644 --- a/tools/visualization/mathjax_renderer.js +++ b/tools/visualization/mathjax_renderer.js @@ -21,8 +21,8 @@ import '@mathjax/src/js/input/tex/gensymb/GensymbConfiguration.js' import '@mathjax/src/js/input/tex/units/UnitsConfiguration.js' import '@mathjax/src/js/input/tex/upgreek/UpgreekConfiguration.js' -// NewCM's dynamic SVG tables are all statically included in BusyMark's one -// deterministic bundle. MathJax still uses its asynchronous retry path, but +// NewCM's dynamic SVG tables are all statically included in BusyMark's dedicated +// offline math bundle. MathJax still uses its asynchronous retry path, but // the loader below only activates already-bundled table registrations. import '@mathjax/mathjax-newcm-font/js/svg/dynamic/PUA.js' import '@mathjax/mathjax-newcm-font/js/svg/dynamic/accents-b-i.js' diff --git a/tools/visualization/mermaid_renderer.js b/tools/visualization/mermaid_renderer.js new file mode 100644 index 00000000..35064fa0 --- /dev/null +++ b/tools/visualization/mermaid_renderer.js @@ -0,0 +1,63 @@ +import mermaid from 'mermaid' + +let renderSequence = 0 + +function diagnostic(error, fallbackCode) { + const message = String(error?.message ?? error?.str ?? error ?? 'Rendering failed.') + const location = error?.hash?.loc + const lineMatch = message.match(/(?:line|at line)\s+(\d+)/i) + return { + code: String(error?.code ?? fallbackCode), + message: message.slice(0, 2000), + severity: 'error', + ...(Number.isInteger(location?.first_line) + ? { line: location.first_line, column: (location.first_column ?? 0) + 1 } + : lineMatch + ? { line: Number.parseInt(lineMatch[1], 10) } + : {}), + } +} + +export async function renderMermaid(source, theme) { + mermaid.initialize({ + startOnLoad: false, + securityLevel: 'strict', + htmlLabels: false, + maxTextSize: 100000, + maxEdges: 500, + suppressErrorRendering: true, + deterministicIds: true, + deterministicIDSeed: 'busymark', + theme: 'base', + themeVariables: theme === 'dark' + ? { + background: '#202124', + primaryColor: '#303134', + primaryTextColor: '#f1f3f4', + primaryBorderColor: '#8ab4f8', + lineColor: '#bdc1c6', + secondaryColor: '#3c4043', + tertiaryColor: '#292a2d', + } + : { + background: '#ffffff', + primaryColor: '#e8f0fe', + primaryTextColor: '#202124', + primaryBorderColor: '#1a73e8', + lineColor: '#5f6368', + secondaryColor: '#f1f3f4', + tertiaryColor: '#ffffff', + }, + }) + try { + const id = `busymark-mermaid-${++renderSequence}` + const { svg } = await mermaid.render(id, source) + return { svg, diagnostics: [] } + } catch (error) { + return { + code: 'visualization.invalidMermaid', + message: String(error?.message ?? error ?? 'Mermaid could not render this block.'), + diagnostics: [diagnostic(error, 'visualization.invalidMermaid')], + } + } +} diff --git a/tools/visualization/openapi_renderer.js b/tools/visualization/openapi_renderer.js new file mode 100644 index 00000000..27479174 --- /dev/null +++ b/tools/visualization/openapi_renderer.js @@ -0,0 +1,395 @@ +import { bundle } from '@scalar/json-magic/bundle' +import { normalize, validate } from '@scalar/openapi-parser' +import { LineCounter, parseDocument } from 'yaml' + +const httpMethods = new Set([ + 'get', + 'put', + 'post', + 'delete', + 'options', + 'head', + 'patch', + 'trace', +]) + +function diagnostic(error, fallbackCode) { + const message = String(error?.message ?? error?.str ?? error ?? 'Rendering failed.') + const location = error?.hash?.loc + const lineMatch = message.match(/(?:line|at line)\s+(\d+)/i) + return { + code: String(error?.code ?? fallbackCode), + message: message.slice(0, 2000), + severity: 'error', + ...(Number.isInteger(location?.first_line) + ? { line: location.first_line, column: (location.first_column ?? 0) + 1 } + : lineMatch + ? { line: Number.parseInt(lineMatch[1], 10) } + : {}), + } +} + +function collectReferences(value, references = new Set()) { + if (Array.isArray(value)) { + for (const item of value) collectReferences(item, references) + return [...references] + } + if (!value || typeof value !== 'object') return [...references] + for (const [key, item] of Object.entries(value)) { + if (key === '$ref' && typeof item === 'string' && !item.startsWith('#')) { + references.add(item.split('#', 1)[0]) + } else { + collectReferences(item, references) + } + } + return [...references] +} + +function collectSourceReferences(value, sourceMap, path = [], references = []) { + if (Array.isArray(value)) { + value.forEach((item, index) => collectSourceReferences(item, sourceMap, [...path, index], references)) + return references + } + if (!value || typeof value !== 'object') return references + for (const [key, item] of Object.entries(value)) { + const itemPath = [...path, key] + if (key === '$ref' && typeof item === 'string' && !item.startsWith('#')) { + const node = sourceMap.document.getIn(itemPath, true) + const position = Number.isInteger(node?.range?.[0]) + ? sourceMap.lineCounter.linePos(node.range[0]) + : undefined + references.push({ + value: item.split('#', 1)[0], + ...(position ? { line: position.line, column: position.col } : {}), + }) + } else { + collectSourceReferences(item, sourceMap, itemPath, references) + } + } + return references +} + +function portableDirectory(filename) { + const index = filename.lastIndexOf('/') + return index < 0 ? '' : filename.slice(0, index) +} + +function normalizePortablePath(path) { + const segments = [] + for (const segment of path.split('/')) { + if (!segment || segment === '.') continue + if (segment === '..') segments.pop() + else segments.push(segment) + } + return segments.join('/') +} + +function canonicalizeReferences(value, filename) { + if (Array.isArray(value)) { + for (const item of value) canonicalizeReferences(item, filename) + return + } + if (!value || typeof value !== 'object') return + for (const [key, item] of Object.entries(value)) { + if (key === '$ref' && typeof item === 'string' && !item.startsWith('#')) { + const [path, fragment] = item.split('#', 2) + value[key] = `${normalizePortablePath(`${portableDirectory(filename)}/${decodeURIComponent(path)}`)}${fragment === undefined ? '' : `#${fragment}`}` + } else { + canonicalizeReferences(item, filename) + } + } +} + +class OpenApiSourceError extends Error { + constructor(message, diagnostics) { + super(message) + this.name = 'OpenApiSourceError' + this.diagnostics = diagnostics + } +} + +function sourceDiagnostic(error, sourceMap, severity = 'error') { + const position = error?.linePos?.[0] + ?? (Number.isInteger(error?.pos?.[0]) ? sourceMap.lineCounter.linePos(error.pos[0]) : undefined) + const message = String(error?.message ?? 'OpenAPI source could not be parsed.').slice(0, 2000) + if (!sourceMap.entrypoint) { + return { + code: String(error?.code ?? 'visualization.invalidOpenApi'), + message, + severity, + sourceId: sourceMap.id, + ...(position ? { sourceLine: position.line, sourceColumn: position.col } : {}), + } + } + return { + code: String(error?.code ?? 'visualization.invalidOpenApi'), + message, + severity, + ...(position ? { line: position.line, column: position.col } : {}), + } +} + +function createSourceMap(id, source, entrypoint) { + const lineCounter = new LineCounter() + const document = parseDocument(source, { + lineCounter, + maxAliasCount: 10000, + merge: true, + }) + const sourceMap = { id, document, entrypoint, lineCounter } + const errors = document.errors.map((error) => sourceDiagnostic(error, sourceMap)) + if (errors.length > 0) { + throw new OpenApiSourceError('OpenAPI file could not be parsed: ' + id, errors) + } + return { + ...sourceMap, + warnings: document.warnings.map((warning) => sourceDiagnostic(warning, sourceMap, 'warning')), + } +} + +function parseFile(id, source, entrypoint) { + const sourceMap = createSourceMap(id, source, entrypoint) + const rawSpecification = normalize(source) + if (!rawSpecification || typeof rawSpecification !== 'object' || Array.isArray(rawSpecification)) { + throw new Error('OpenAPI file could not be parsed: ' + id) + } + const specification = structuredClone(rawSpecification) + canonicalizeReferences(specification, id) + return { + file: { + dir: portableDirectory(id), + filename: id, + isEntrypoint: entrypoint, + references: collectReferences(specification), + specification, + }, + rawSpecification, + sourceMap, + } +} + +function validationPathSegments(path) { + if (Array.isArray(path)) return path.map((segment) => String(segment)) + if (typeof path !== 'string' || path.length === 0 || !path.startsWith('/')) return [] + return path.slice(1).split('/').map((segment) => segment.replaceAll('~1', '/').replaceAll('~0', '~')) +} + +function validationLocation(sourceMap, requestedPath) { + const path = [...requestedPath] + while (path.length > 0) { + const node = sourceMap.document.getIn(path, true) + if (Number.isInteger(node?.range?.[0])) return sourceMap.lineCounter.linePos(node.range[0]) + path.pop() + } + return undefined +} + +function pathStartsWith(path, prefix) { + return prefix.length <= path.length && prefix.every((segment, index) => segment === path[index]) +} + +function sourceMapForBundledUrl(value, parsedFiles) { + if (typeof value !== 'string') return undefined + const portableValue = normalizePortablePath(value.replace(/^\/+/, '')) + const entryDirectory = portableDirectory(parsedFiles[0].sourceMap.id) + const entryRelative = normalizePortablePath(`${entryDirectory}/${portableValue}`) + return parsedFiles + .map((parsed) => parsed.sourceMap) + .find((sourceMap) => sourceMap.id === portableValue || sourceMap.id === entryRelative) +} + +function bundledReferenceProvenance(document, parsedFiles) { + const mappings = document?.['x-ext-urls'] + if (!mappings || typeof mappings !== 'object' || Array.isArray(mappings)) return [] + const sourceMapsByHash = new Map( + Object.entries(mappings) + .map(([hash, value]) => [hash, sourceMapForBundledUrl(value, parsedFiles)]) + .filter(([, sourceMap]) => sourceMap !== undefined), + ) + const provenance = [] + const visit = (value, bundledPath = []) => { + if (Array.isArray(value)) { + value.forEach((item, index) => visit(item, [...bundledPath, String(index)])) + return + } + if (!value || typeof value !== 'object') return + const ownerSourceMap = bundledPath[0] === 'x-ext' + ? sourceMapsByHash.get(bundledPath[1]) + : parsedFiles[0].sourceMap + const ownerPath = bundledPath[0] === 'x-ext' ? bundledPath.slice(2) : bundledPath + if (typeof value.$ref === 'string' && value.$ref.startsWith('#/x-ext/')) { + const target = validationPathSegments(value.$ref.slice(1)) + const targetSourceMap = sourceMapsByHash.get(target[1]) + if (ownerSourceMap && targetSourceMap) { + provenance.push({ + ownerId: ownerSourceMap.id, + renderedPath: ownerPath, + targetSourceMap, + targetPath: target.slice(2), + }) + } + } + for (const [key, item] of Object.entries(value)) { + if (key !== 'x-ext-urls') visit(item, [...bundledPath, key]) + } + } + visit(document) + return provenance.sort((left, right) => right.renderedPath.length - left.renderedPath.length) +} + +function validationAttribution(error, parsedFiles, provenance) { + let sourceMap = parsedFiles[0].sourceMap + let path = validationPathSegments(error?.path) + const visited = new Set() + for (;;) { + const mapping = provenance.find((candidate) => + candidate.ownerId === sourceMap.id && pathStartsWith(path, candidate.renderedPath)) + if (!mapping) break + const key = `${mapping.ownerId}\u0000${mapping.renderedPath.join('\u0000')}\u0000${mapping.targetSourceMap.id}` + if (visited.has(key)) break + visited.add(key) + path = [...mapping.targetPath, ...path.slice(mapping.renderedPath.length)] + sourceMap = mapping.targetSourceMap + } + return { sourceMap, location: validationLocation(sourceMap, path) } +} + +function validationDiagnostics(errors, parsedFiles, provenance) { + return (errors ?? []).slice(0, 200).map((error) => { + const { sourceMap, location } = validationAttribution(error, parsedFiles, provenance) + return { + code: String(error?.code ?? 'visualization.invalidOpenApi'), + message: String(error?.message ?? 'OpenAPI validation failed.').slice(0, 2000), + severity: 'error', + ...(sourceMap.entrypoint + ? (location ? { line: location.line, column: location.col } : {}) + : { + sourceId: sourceMap.id, + ...(location ? { sourceLine: location.line, sourceColumn: location.col } : {}), + }), + } + }) +} + +function uniqueValidationErrors(...groups) { + const byKey = new Map() + for (const error of groups.flat()) { + const key = String(error?.code ?? '') + '\u0000' + String(error?.path ?? '') + '\u0000' + String(error?.message ?? '') + if (!byKey.has(key)) byKey.set(key, error) + } + return [...byKey.values()] +} + +async function bundleOpenApi(parsedFiles) { + const entry = parsedFiles[0] + const documents = new Map( + parsedFiles.slice(1).map((parsed) => ['/' + parsed.file.filename, parsed.rawSpecification]), + ) + const document = await bundle(structuredClone(entry.rawSpecification), { + origin: '/' + entry.file.filename, + plugins: [{ + type: 'loader', + validate: (value) => documents.has(value), + exec: async (value) => { + const document = documents.get(value) + return document + ? { ok: true, data: structuredClone(document), raw: '' } + : { ok: false } + }, + }], + treeShake: false, + urlMap: true, + }) + const provenance = bundledReferenceProvenance(document, parsedFiles) + delete document['x-ext-urls'] + return { document, provenance } +} + +function operationSummary(document) { + const operations = [] + const tags = new Set( + Array.isArray(document.tags) + ? document.tags.map((tag) => tag?.name).filter((tag) => typeof tag === 'string') + : [], + ) + const paths = document.paths && typeof document.paths === 'object' ? document.paths : {} + for (const [path, pathItem] of Object.entries(paths)) { + if (!pathItem || typeof pathItem !== 'object') continue + for (const [method, operation] of Object.entries(pathItem)) { + if (!httpMethods.has(method.toLowerCase()) || !operation || typeof operation !== 'object') continue + const operationTags = Array.isArray(operation.tags) + ? operation.tags.filter((tag) => typeof tag === 'string') + : [] + for (const tag of operationTags) tags.add(tag) + operations.push({ + method: method.toUpperCase(), + path, + summary: typeof operation.summary === 'string' ? operation.summary : '', + operationId: typeof operation.operationId === 'string' ? operation.operationId : '', + tags: operationTags, + }) + } + } + operations.sort((left, right) => left.path.localeCompare(right.path) || left.method.localeCompare(right.method)) + return { operations, tags: [...tags].sort(), pathCount: Object.keys(paths).length } +} + +export async function prepareOpenApi(request) { + const entryId = request.entryId || 'document.openapi' + const parsedFiles = [ + parseFile(entryId, request.source, true), + ...(request.dependencies ?? []).map((dependency) => parseFile(dependency.id, dependency.source, false)), + ] + const files = parsedFiles.map((parsed) => parsed.file) + const validation = await validate(files) + const bundled = await bundleOpenApi(parsedFiles) + const bundledDocument = bundled.document + const bundledValidation = await validate(bundledDocument) + const errors = uniqueValidationErrors(validation.errors ?? [], bundledValidation.errors ?? []) + const document = files[0].specification + const summaryDocument = bundledValidation.schema ?? validation.schema ?? bundledDocument + const summary = operationSummary(summaryDocument) + const specificationVersion = typeof document.openapi === 'string' + ? document.openapi + : typeof document.swagger === 'string' + ? document.swagger + : '' + return { + scalarContent: bundledDocument, + response: { + reference: { + title: typeof document.info?.title === 'string' ? document.info.title : 'OpenAPI', + apiVersion: typeof document.info?.version === 'string' ? document.info.version : '', + specificationVersion, + valid: validation.valid === true && bundledValidation.valid === true, + serverCount: Array.isArray(document.servers) ? document.servers.length : 0, + pathCount: summary.pathCount, + operations: summary.operations, + tags: summary.tags, + document: bundledDocument, + externalDocuments: parsedFiles.slice(1).map((parsed) => ({ + id: parsed.file.filename, + document: parsed.rawSpecification, + })), + }, + diagnostics: [ + ...parsedFiles.flatMap((parsed) => parsed.sourceMap.warnings), + ...validationDiagnostics(errors, parsedFiles, bundled.provenance), + ], + }, + } +} + +export function inspectOpenApi(source) { + try { + const sourceMap = createSourceMap('document.openapi', source, true) + const specification = normalize(source) + return { + references: specification && typeof specification === 'object' + ? collectSourceReferences(specification, sourceMap) + : [], + } + } catch { + return { references: [] } + } +} diff --git a/tools/visualization/plantuml_renderer.js b/tools/visualization/plantuml_renderer.js new file mode 100644 index 00000000..686226ab --- /dev/null +++ b/tools/visualization/plantuml_renderer.js @@ -0,0 +1,105 @@ +import { + render as renderPlantUmlIntoElement, + renderToString as renderPlantUmlToString, +} from '@plantuml/core' + +let renderSequence = 0 + +let graphvizLoad + +function loadGraphviz() { + graphvizLoad ??= new Promise((resolve, reject) => { + const script = document.createElement('script') + script.src = './viz-global.js' + script.onload = () => resolve() + script.onerror = () => { + script.remove() + graphvizLoad = undefined + const error = new Error('The bundled Graphviz engine could not be loaded.') + error.busymarkEngineLoadFailed = true + reject(error) + } + document.head.append(script) + }) + return graphvizLoad +} + +function diagnostic(error, fallbackCode) { + const message = String(error?.message ?? error?.str ?? error ?? 'Rendering failed.') + const location = error?.hash?.loc + const lineMatch = message.match(/(?:line|at line)\s+(\d+)/i) + return { + code: String(error?.code ?? fallbackCode), + message: message.slice(0, 2000), + severity: 'error', + ...(Number.isInteger(location?.first_line) + ? { line: location.first_line, column: (location.first_column ?? 0) + 1 } + : lineMatch + ? { line: Number.parseInt(lineMatch[1], 10) } + : {}), + } +} + +function renderDarkPlantUml(lines) { + return new Promise((resolve, reject) => { + const target = document.createElement('div') + target.id = `busymark-plantuml-${++renderSequence}` + target.style.cssText = 'position:fixed;left:-100000px;top:0;opacity:0;pointer-events:none' + document.body.append(target) + const cleanup = () => { + observer.disconnect() + window.clearTimeout(timeout) + target.remove() + } + const observer = new MutationObserver(() => { + const svg = target.querySelector('svg') + if (!svg) return + const result = new XMLSerializer().serializeToString(svg) + cleanup() + resolve(result) + }) + const timeout = window.setTimeout(() => { + cleanup() + reject(new Error('PlantUML did not finish rendering.')) + }, 15000) + observer.observe(target, { childList: true, subtree: true }) + renderPlantUmlIntoElement(lines, target.id, { dark: true }) + }) +} + +export async function renderPlantUml(source, theme) { + await loadGraphviz() + return new Promise((resolve) => { + const lines = source.replace(/\r\n?/g, '\n').split('\n') + try { + renderPlantUmlToString( + lines, + async (svg) => { + try { + resolve({ + svg: theme === 'dark' ? await renderDarkPlantUml(lines) : svg, + diagnostics: [], + }) + } catch (error) { + resolve({ + code: 'visualization.invalidPlantUml', + message: String(error?.message ?? error ?? 'PlantUML could not render this block.'), + diagnostics: [diagnostic(error, 'visualization.invalidPlantUml')], + }) + } + }, + (error) => resolve({ + code: 'visualization.invalidPlantUml', + message: String(error ?? 'PlantUML could not render this block.'), + diagnostics: [diagnostic(error, 'visualization.invalidPlantUml')], + }), + ) + } catch (error) { + resolve({ + code: 'visualization.invalidPlantUml', + message: String(error?.message ?? error ?? 'PlantUML could not render this block.'), + diagnostics: [diagnostic(error, 'visualization.invalidPlantUml')], + }) + } + }) +} diff --git a/tools/visualization/reference.html b/tools/visualization/reference.html index e3dc70c3..9df3fdb2 100644 --- a/tools/visualization/reference.html +++ b/tools/visualization/reference.html @@ -7,7 +7,6 @@ BusyMark API Reference - diff --git a/tools/visualization/render_engines.js b/tools/visualization/render_engines.js index 15772b28..41a4238e 100644 --- a/tools/visualization/render_engines.js +++ b/tools/visualization/render_engines.js @@ -1,26 +1,6 @@ -import { - render as renderPlantUmlIntoElement, - renderToString as renderPlantUmlToString, -} from '@plantuml/core' -import { bundle } from '@scalar/json-magic/bundle' -import { normalize, validate } from '@scalar/openapi-parser' -import mermaid from 'mermaid' -import { LineCounter, parseDocument } from 'yaml' -import { renderMathBatch } from './mathjax_renderer.js' - -const httpMethods = new Set([ - 'get', - 'put', - 'post', - 'delete', - 'options', - 'head', - 'patch', - 'trace', -]) - -let renderSequence = 0 - +// Each fixed offline engine bundle is loaded only when its operation is used. +// Module imports share evaluation within the reusable view. Rasterization needs +// only this small harness; recovery starts with a fresh module registry. function diagnostic(error, fallbackCode) { const message = String(error?.message ?? error?.str ?? error ?? 'Rendering failed.') const location = error?.hash?.loc @@ -37,489 +17,37 @@ function diagnostic(error, fallbackCode) { } } -async function renderMermaid(source, theme) { - mermaid.initialize({ - startOnLoad: false, - securityLevel: 'strict', - htmlLabels: false, - maxTextSize: 100000, - maxEdges: 500, - suppressErrorRendering: true, - deterministicIds: true, - deterministicIDSeed: 'busymark', - theme: 'base', - themeVariables: theme === 'dark' - ? { - background: '#202124', - primaryColor: '#303134', - primaryTextColor: '#f1f3f4', - primaryBorderColor: '#8ab4f8', - lineColor: '#bdc1c6', - secondaryColor: '#3c4043', - tertiaryColor: '#292a2d', - } - : { - background: '#ffffff', - primaryColor: '#e8f0fe', - primaryTextColor: '#202124', - primaryBorderColor: '#1a73e8', - lineColor: '#5f6368', - secondaryColor: '#f1f3f4', - tertiaryColor: '#ffffff', - }, - }) +async function loadEngine(load) { try { - const id = `busymark-mermaid-${++renderSequence}` - const { svg } = await mermaid.render(id, source) - return { svg, diagnostics: [] } - } catch (error) { - return { - code: 'visualization.invalidMermaid', - message: String(error?.message ?? error ?? 'Mermaid could not render this block.'), - diagnostics: [diagnostic(error, 'visualization.invalidMermaid')], - } - } -} - -function renderDarkPlantUml(lines) { - return new Promise((resolve, reject) => { - const target = document.createElement('div') - target.id = `busymark-plantuml-${++renderSequence}` - target.style.cssText = 'position:fixed;left:-100000px;top:0;opacity:0;pointer-events:none' - document.body.append(target) - const cleanup = () => { - observer.disconnect() - window.clearTimeout(timeout) - target.remove() - } - const observer = new MutationObserver(() => { - const svg = target.querySelector('svg') - if (!svg) return - const result = new XMLSerializer().serializeToString(svg) - cleanup() - resolve(result) - }) - const timeout = window.setTimeout(() => { - cleanup() - reject(new Error('PlantUML did not finish rendering.')) - }, 15000) - observer.observe(target, { childList: true, subtree: true }) - renderPlantUmlIntoElement(lines, target.id, { dark: true }) - }) -} - -function renderPlantUml(source, theme) { - return new Promise((resolve) => { - const lines = source.replace(/\r\n?/g, '\n').split('\n') - try { - renderPlantUmlToString( - lines, - async (svg) => { - try { - resolve({ - svg: theme === 'dark' ? await renderDarkPlantUml(lines) : svg, - diagnostics: [], - }) - } catch (error) { - resolve({ - code: 'visualization.invalidPlantUml', - message: String(error?.message ?? error ?? 'PlantUML could not render this block.'), - diagnostics: [diagnostic(error, 'visualization.invalidPlantUml')], - }) - } - }, - (error) => resolve({ - code: 'visualization.invalidPlantUml', - message: String(error ?? 'PlantUML could not render this block.'), - diagnostics: [diagnostic(error, 'visualization.invalidPlantUml')], - }), - ) - } catch (error) { - resolve({ - code: 'visualization.invalidPlantUml', - message: String(error?.message ?? error ?? 'PlantUML could not render this block.'), - diagnostics: [diagnostic(error, 'visualization.invalidPlantUml')], - }) - } - }) -} - -function collectReferences(value, references = new Set()) { - if (Array.isArray(value)) { - for (const item of value) collectReferences(item, references) - return [...references] - } - if (!value || typeof value !== 'object') return [...references] - for (const [key, item] of Object.entries(value)) { - if (key === '$ref' && typeof item === 'string' && !item.startsWith('#')) { - references.add(item.split('#', 1)[0]) - } else { - collectReferences(item, references) - } - } - return [...references] -} - -function collectSourceReferences(value, sourceMap, path = [], references = []) { - if (Array.isArray(value)) { - value.forEach((item, index) => collectSourceReferences(item, sourceMap, [...path, index], references)) - return references - } - if (!value || typeof value !== 'object') return references - for (const [key, item] of Object.entries(value)) { - const itemPath = [...path, key] - if (key === '$ref' && typeof item === 'string' && !item.startsWith('#')) { - const node = sourceMap.document.getIn(itemPath, true) - const position = Number.isInteger(node?.range?.[0]) - ? sourceMap.lineCounter.linePos(node.range[0]) - : undefined - references.push({ - value: item.split('#', 1)[0], - ...(position ? { line: position.line, column: position.col } : {}), - }) - } else { - collectSourceReferences(item, sourceMap, itemPath, references) - } - } - return references -} - -function portableDirectory(filename) { - const index = filename.lastIndexOf('/') - return index < 0 ? '' : filename.slice(0, index) -} - -function normalizePortablePath(path) { - const segments = [] - for (const segment of path.split('/')) { - if (!segment || segment === '.') continue - if (segment === '..') segments.pop() - else segments.push(segment) - } - return segments.join('/') -} - -function canonicalizeReferences(value, filename) { - if (Array.isArray(value)) { - for (const item of value) canonicalizeReferences(item, filename) - return - } - if (!value || typeof value !== 'object') return - for (const [key, item] of Object.entries(value)) { - if (key === '$ref' && typeof item === 'string' && !item.startsWith('#')) { - const [path, fragment] = item.split('#', 2) - value[key] = `${normalizePortablePath(`${portableDirectory(filename)}/${decodeURIComponent(path)}`)}${fragment === undefined ? '' : `#${fragment}`}` - } else { - canonicalizeReferences(item, filename) - } - } -} - -class OpenApiSourceError extends Error { - constructor(message, diagnostics) { - super(message) - this.name = 'OpenApiSourceError' - this.diagnostics = diagnostics - } -} - -function sourceDiagnostic(error, sourceMap, severity = 'error') { - const position = error?.linePos?.[0] - ?? (Number.isInteger(error?.pos?.[0]) ? sourceMap.lineCounter.linePos(error.pos[0]) : undefined) - const message = String(error?.message ?? 'OpenAPI source could not be parsed.').slice(0, 2000) - if (!sourceMap.entrypoint) { - return { - code: String(error?.code ?? 'visualization.invalidOpenApi'), - message, - severity, - sourceId: sourceMap.id, - ...(position ? { sourceLine: position.line, sourceColumn: position.col } : {}), - } - } - return { - code: String(error?.code ?? 'visualization.invalidOpenApi'), - message, - severity, - ...(position ? { line: position.line, column: position.col } : {}), - } -} - -function createSourceMap(id, source, entrypoint) { - const lineCounter = new LineCounter() - const document = parseDocument(source, { - lineCounter, - maxAliasCount: 10000, - merge: true, - }) - const sourceMap = { id, document, entrypoint, lineCounter } - const errors = document.errors.map((error) => sourceDiagnostic(error, sourceMap)) - if (errors.length > 0) { - throw new OpenApiSourceError('OpenAPI file could not be parsed: ' + id, errors) - } - return { - ...sourceMap, - warnings: document.warnings.map((warning) => sourceDiagnostic(warning, sourceMap, 'warning')), - } -} - -function parseFile(id, source, entrypoint) { - const sourceMap = createSourceMap(id, source, entrypoint) - const rawSpecification = normalize(source) - if (!rawSpecification || typeof rawSpecification !== 'object' || Array.isArray(rawSpecification)) { - throw new Error('OpenAPI file could not be parsed: ' + id) - } - const specification = structuredClone(rawSpecification) - canonicalizeReferences(specification, id) - return { - file: { - dir: portableDirectory(id), - filename: id, - isEntrypoint: entrypoint, - references: collectReferences(specification), - specification, - }, - rawSpecification, - sourceMap, - } -} - -function validationPathSegments(path) { - if (Array.isArray(path)) return path.map((segment) => String(segment)) - if (typeof path !== 'string' || path.length === 0 || !path.startsWith('/')) return [] - return path.slice(1).split('/').map((segment) => segment.replaceAll('~1', '/').replaceAll('~0', '~')) -} - -function validationLocation(sourceMap, requestedPath) { - const path = [...requestedPath] - while (path.length > 0) { - const node = sourceMap.document.getIn(path, true) - if (Number.isInteger(node?.range?.[0])) return sourceMap.lineCounter.linePos(node.range[0]) - path.pop() - } - return undefined -} - -function pathStartsWith(path, prefix) { - return prefix.length <= path.length && prefix.every((segment, index) => segment === path[index]) -} - -function sourceMapForBundledUrl(value, parsedFiles) { - if (typeof value !== 'string') return undefined - const portableValue = normalizePortablePath(value.replace(/^\/+/, '')) - const entryDirectory = portableDirectory(parsedFiles[0].sourceMap.id) - const entryRelative = normalizePortablePath(`${entryDirectory}/${portableValue}`) - return parsedFiles - .map((parsed) => parsed.sourceMap) - .find((sourceMap) => sourceMap.id === portableValue || sourceMap.id === entryRelative) -} - -function bundledReferenceProvenance(document, parsedFiles) { - const mappings = document?.['x-ext-urls'] - if (!mappings || typeof mappings !== 'object' || Array.isArray(mappings)) return [] - const sourceMapsByHash = new Map( - Object.entries(mappings) - .map(([hash, value]) => [hash, sourceMapForBundledUrl(value, parsedFiles)]) - .filter(([, sourceMap]) => sourceMap !== undefined), - ) - const provenance = [] - const visit = (value, bundledPath = []) => { - if (Array.isArray(value)) { - value.forEach((item, index) => visit(item, [...bundledPath, String(index)])) - return - } - if (!value || typeof value !== 'object') return - const ownerSourceMap = bundledPath[0] === 'x-ext' - ? sourceMapsByHash.get(bundledPath[1]) - : parsedFiles[0].sourceMap - const ownerPath = bundledPath[0] === 'x-ext' ? bundledPath.slice(2) : bundledPath - if (typeof value.$ref === 'string' && value.$ref.startsWith('#/x-ext/')) { - const target = validationPathSegments(value.$ref.slice(1)) - const targetSourceMap = sourceMapsByHash.get(target[1]) - if (ownerSourceMap && targetSourceMap) { - provenance.push({ - ownerId: ownerSourceMap.id, - renderedPath: ownerPath, - targetSourceMap, - targetPath: target.slice(2), - }) - } - } - for (const [key, item] of Object.entries(value)) { - if (key !== 'x-ext-urls') visit(item, [...bundledPath, key]) - } + return await load() + } catch { + // WebKit caches failed module imports in the view's module registry. Tell + // the native host to retire that registry before processing queued work. + const error = new Error('The bundled visualization engine could not be loaded.') + error.busymarkEngineLoadFailed = true + throw error } - visit(document) - return provenance.sort((left, right) => right.renderedPath.length - left.renderedPath.length) -} - -function validationAttribution(error, parsedFiles, provenance) { - let sourceMap = parsedFiles[0].sourceMap - let path = validationPathSegments(error?.path) - const visited = new Set() - for (;;) { - const mapping = provenance.find((candidate) => - candidate.ownerId === sourceMap.id && pathStartsWith(path, candidate.renderedPath)) - if (!mapping) break - const key = `${mapping.ownerId}\u0000${mapping.renderedPath.join('\u0000')}\u0000${mapping.targetSourceMap.id}` - if (visited.has(key)) break - visited.add(key) - path = [...mapping.targetPath, ...path.slice(mapping.renderedPath.length)] - sourceMap = mapping.targetSourceMap - } - return { sourceMap, location: validationLocation(sourceMap, path) } -} - -function validationDiagnostics(errors, parsedFiles, provenance) { - return (errors ?? []).slice(0, 200).map((error) => { - const { sourceMap, location } = validationAttribution(error, parsedFiles, provenance) - return { - code: String(error?.code ?? 'visualization.invalidOpenApi'), - message: String(error?.message ?? 'OpenAPI validation failed.').slice(0, 2000), - severity: 'error', - ...(sourceMap.entrypoint - ? (location ? { line: location.line, column: location.col } : {}) - : { - sourceId: sourceMap.id, - ...(location ? { sourceLine: location.line, sourceColumn: location.col } : {}), - }), - } - }) -} - -function uniqueValidationErrors(...groups) { - const byKey = new Map() - for (const error of groups.flat()) { - const key = String(error?.code ?? '') + '\u0000' + String(error?.path ?? '') + '\u0000' + String(error?.message ?? '') - if (!byKey.has(key)) byKey.set(key, error) - } - return [...byKey.values()] -} - -async function bundleOpenApi(parsedFiles) { - const entry = parsedFiles[0] - const documents = new Map( - parsedFiles.slice(1).map((parsed) => ['/' + parsed.file.filename, parsed.rawSpecification]), - ) - const document = await bundle(structuredClone(entry.rawSpecification), { - origin: '/' + entry.file.filename, - plugins: [{ - type: 'loader', - validate: (value) => documents.has(value), - exec: async (value) => { - const document = documents.get(value) - return document - ? { ok: true, data: structuredClone(document), raw: '' } - : { ok: false } - }, - }], - treeShake: false, - urlMap: true, - }) - const provenance = bundledReferenceProvenance(document, parsedFiles) - delete document['x-ext-urls'] - return { document, provenance } -} - -function operationSummary(document) { - const operations = [] - const tags = new Set( - Array.isArray(document.tags) - ? document.tags.map((tag) => tag?.name).filter((tag) => typeof tag === 'string') - : [], - ) - const paths = document.paths && typeof document.paths === 'object' ? document.paths : {} - for (const [path, pathItem] of Object.entries(paths)) { - if (!pathItem || typeof pathItem !== 'object') continue - for (const [method, operation] of Object.entries(pathItem)) { - if (!httpMethods.has(method.toLowerCase()) || !operation || typeof operation !== 'object') continue - const operationTags = Array.isArray(operation.tags) - ? operation.tags.filter((tag) => typeof tag === 'string') - : [] - for (const tag of operationTags) tags.add(tag) - operations.push({ - method: method.toUpperCase(), - path, - summary: typeof operation.summary === 'string' ? operation.summary : '', - operationId: typeof operation.operationId === 'string' ? operation.operationId : '', - tags: operationTags, - }) - } - } - operations.sort((left, right) => left.path.localeCompare(right.path) || left.method.localeCompare(right.method)) - return { operations, tags: [...tags].sort(), pathCount: Object.keys(paths).length } } export async function prepareOpenApi(request) { - const entryId = request.entryId || 'document.openapi' - const parsedFiles = [ - parseFile(entryId, request.source, true), - ...(request.dependencies ?? []).map((dependency) => parseFile(dependency.id, dependency.source, false)), - ] - const files = parsedFiles.map((parsed) => parsed.file) - const validation = await validate(files) - const bundled = await bundleOpenApi(parsedFiles) - const bundledDocument = bundled.document - const bundledValidation = await validate(bundledDocument) - const errors = uniqueValidationErrors(validation.errors ?? [], bundledValidation.errors ?? []) - const document = files[0].specification - const summaryDocument = bundledValidation.schema ?? validation.schema ?? bundledDocument - const summary = operationSummary(summaryDocument) - const specificationVersion = typeof document.openapi === 'string' - ? document.openapi - : typeof document.swagger === 'string' - ? document.swagger - : '' - return { - scalarContent: bundledDocument, - response: { - reference: { - title: typeof document.info?.title === 'string' ? document.info.title : 'OpenAPI', - apiVersion: typeof document.info?.version === 'string' ? document.info.version : '', - specificationVersion, - valid: validation.valid === true && bundledValidation.valid === true, - serverCount: Array.isArray(document.servers) ? document.servers.length : 0, - pathCount: summary.pathCount, - operations: summary.operations, - tags: summary.tags, - document: bundledDocument, - externalDocuments: parsedFiles.slice(1).map((parsed) => ({ - id: parsed.file.filename, - document: parsed.rawSpecification, - })), - }, - diagnostics: [ - ...parsedFiles.flatMap((parsed) => parsed.sourceMap.warnings), - ...validationDiagnostics(errors, parsedFiles, bundled.provenance), - ], - }, - } + return (await loadEngine(() => import('./openapi-engine.js'))).prepareOpenApi(request) } async function handleRequest(request) { switch (request.operation) { case 'renderMermaid': - return renderMermaid(request.source, request.theme) + return (await loadEngine(() => import('./mermaid-engine.js'))).renderMermaid(request.source, request.theme) case 'renderPlantUml': - return renderPlantUml(request.source, request.theme) + return (await loadEngine(() => import('./plantuml-engine.js'))).renderPlantUml(request.source, request.theme) case 'renderMathBatch': - return renderMathBatch(request) - case 'inspectOpenApi': { - try { - const sourceMap = createSourceMap('document.openapi', request.source, true) - const specification = normalize(request.source) - return { - references: specification && typeof specification === 'object' - ? collectSourceReferences(specification, sourceMap) - : [], - } - } catch { - return { references: [] } - } - } + return (await loadEngine(() => import('./math-engine.js'))).renderMathBatch(request) + case 'inspectOpenApi': + return (await loadEngine(() => import('./openapi-engine.js'))).inspectOpenApi(request.source) case 'parseOpenApi': try { return (await prepareOpenApi(request)).response } catch (error) { + if (error?.busymarkEngineLoadFailed === true) throw error return { code: 'visualization.invalidOpenApi', message: String(error?.message ?? error ?? 'The OpenAPI document could not be parsed.'), diff --git a/tools/visualization_smoke.py b/tools/visualization_smoke.py index 4aee7658..5f2fc357 100755 --- a/tools/visualization_smoke.py +++ b/tools/visualization_smoke.py @@ -86,6 +86,10 @@ def _serve(self, request: WebKit2.URISchemeRequest) -> None: "reference.html", "bootstrap.js", "render-engines.js", + "mermaid-engine.js", + "plantuml-engine.js", + "openapi-engine.js", + "math-engine.js", "reference.js", "scalar.js", "viz-global.js", diff --git a/tools/web_render_host_probe.cc b/tools/web_render_host_probe.cc new file mode 100644 index 00000000..c9cf29f9 --- /dev/null +++ b/tools/web_render_host_probe.cc @@ -0,0 +1,675 @@ +// Runs the production native channel and real WebKit; only Flutter transport +// and a one-shot context-allocation failure are injected. No Dart mock host. +#include +#include +#include +#include + +#include "../linux/runner/web_render_host.cc" + +constexpr char kMethods[] = "io.busystack.busymark/visualization"; +struct Handler { + FlBinaryMessengerMessageHandler callback; + gpointer data; + GDestroyNotify destroy; +}; + +struct TestMessenger { + GObject parent; + GHashTable* handlers; + GPtrArray* events; +}; +struct TestMessengerClass { + GObjectClass parent; +}; +static void messenger_interface_init(FlBinaryMessengerInterface* iface); +G_DEFINE_TYPE_WITH_CODE(TestMessenger, test_messenger, G_TYPE_OBJECT, + G_IMPLEMENT_INTERFACE(fl_binary_messenger_get_type(), + messenger_interface_init)) + +struct TestResponse { + FlBinaryMessengerResponseHandle parent; + GBytes* bytes; + bool responded; +}; +struct TestResponseClass { + FlBinaryMessengerResponseHandleClass parent; +}; +G_DEFINE_TYPE(TestResponse, test_response, + fl_binary_messenger_response_handle_get_type()) + +static void test_response_finalize(GObject* object) { + g_clear_pointer(&reinterpret_cast(object)->bytes, + g_bytes_unref); + G_OBJECT_CLASS(test_response_parent_class)->finalize(object); +} +static void test_response_class_init(TestResponseClass* klass) { + G_OBJECT_CLASS(klass)->finalize = test_response_finalize; +} +static void test_response_init(TestResponse*) {} +G_DEFINE_AUTOPTR_CLEANUP_FUNC(TestResponse, g_object_unref) + +static void free_handler(gpointer data) { + auto* handler = static_cast(data); + if (handler->destroy != nullptr) handler->destroy(handler->data); + delete handler; +} +static void set_handler(FlBinaryMessenger* messenger, const gchar* channel, + FlBinaryMessengerMessageHandler callback, gpointer data, + GDestroyNotify destroy) { + auto* self = reinterpret_cast(messenger); + g_hash_table_remove(self->handlers, channel); + if (callback != nullptr) { + g_hash_table_insert(self->handlers, g_strdup(channel), + new Handler{callback, data, destroy}); + } else if (destroy != nullptr) { + destroy(data); + } +} +static gboolean send_response(FlBinaryMessenger*, + FlBinaryMessengerResponseHandle* handle, + GBytes* bytes, GError**) { + auto* response = reinterpret_cast(handle); + g_assert_false(response->responded); + response->responded = true; + response->bytes = + bytes != nullptr ? g_bytes_ref(bytes) : g_bytes_new(nullptr, 0); + return TRUE; +} +static void send_event(FlBinaryMessenger* messenger, const gchar* channel, + GBytes* message, GCancellable*, + GAsyncReadyCallback callback, gpointer) { + (void)channel; + g_assert_null(callback); // FlEventChannel sends unacknowledged envelopes. + g_assert_nonnull(message); + g_ptr_array_add(reinterpret_cast(messenger)->events, + g_bytes_ref(message)); +} +static void shutdown_messenger(FlBinaryMessenger* messenger) { + g_hash_table_remove_all( + reinterpret_cast(messenger)->handlers); +} +static void messenger_interface_init(FlBinaryMessengerInterface* iface) { + iface->set_message_handler_on_channel = set_handler; + iface->send_response = send_response; + iface->send_on_channel = send_event; + iface->shutdown = shutdown_messenger; +} +static void test_messenger_finalize(GObject* object) { + auto* self = reinterpret_cast(object); + g_hash_table_unref(self->handlers); + g_ptr_array_unref(self->events); + G_OBJECT_CLASS(test_messenger_parent_class)->finalize(object); +} +static void test_messenger_class_init(TestMessengerClass* klass) { + G_OBJECT_CLASS(klass)->finalize = test_messenger_finalize; +} +static void test_messenger_init(TestMessenger* self) { + self->handlers = + g_hash_table_new_full(g_str_hash, g_str_equal, g_free, free_handler); + self->events = g_ptr_array_new_with_free_func( + reinterpret_cast(g_bytes_unref)); +} + +static TestMessenger* active_messenger = nullptr; +// --wrap affects the production host's call, not calls inside Flutter's shared +// library. This is the sole injected I/O boundary; channel handlers are real. +extern "C" FlBinaryMessenger* __wrap_fl_engine_get_binary_messenger( + FlEngine* engine) { + g_assert_true(FL_IS_ENGINE(engine)); + g_assert_nonnull(active_messenger); + return FL_BINARY_MESSENGER(active_messenger); +} + +static void drain() { + int iterations = 0; + while (g_main_context_pending(nullptr)) { + g_main_context_iteration(nullptr, FALSE); + g_assert_cmpint(++iterations, <, 10000); + } +} + +extern "C" WebKitWebContext* __real_webkit_web_context_new_ephemeral(); +static unsigned contexts = 0; +static unsigned views = 0; +static unsigned harness_loads = 0; +extern "C" GtkWidget* __real_webkit_web_view_new_with_context( + WebKitWebContext*); +extern "C" GtkWidget* __wrap_webkit_web_view_new_with_context( + WebKitWebContext* context) { + ++views; + return __real_webkit_web_view_new_with_context(context); +} +extern "C" void __real_webkit_web_view_load_uri(WebKitWebView*, const gchar*); +extern "C" void __wrap_webkit_web_view_load_uri(WebKitWebView* view, + const gchar* uri) { + if (g_strcmp0(uri, kHarnessUri) == 0) ++harness_loads; + __real_webkit_web_view_load_uri(view, uri); +} +static bool fail_context = false; +extern "C" WebKitWebContext* __wrap_webkit_web_context_new_ephemeral() { + ++contexts; + if (fail_context) { + fail_context = false; + return nullptr; + } + return __real_webkit_web_context_new_ephemeral(); +} + +static std::map resource_reads; +static bool fail_engine_read = false; +static const char* failed_engine_resource = "mermaid-engine.js"; +extern "C" gboolean __real_g_file_get_contents(const gchar*, gchar**, gsize*, + GError**); +extern "C" gboolean __wrap_g_file_get_contents(const gchar* path, + gchar** contents, gsize* length, + GError** error) { + const char* name = strrchr(path, '/'); + if (name != nullptr) { + ++resource_reads[name + 1]; + if (fail_engine_read && strcmp(name + 1, failed_engine_resource) == 0) { + fail_engine_read = false; + g_set_error_literal(error, G_IO_ERROR, G_IO_ERROR_NOT_FOUND, + "Injected missing engine bundle"); + return FALSE; + } + } + return __real_g_file_get_contents(path, contents, length, error); +} + +static void only_engine(const char* expected) { + for (const char* engine : {"mermaid", "plantuml", "math", "openapi"}) { + const std::string name = std::string(engine) + "-engine.js"; + g_assert_cmpuint( + resource_reads[name], ==, + expected != nullptr && strcmp(expected, engine) == 0 ? 1 : 0); + } + g_assert_cmpuint( + resource_reads["viz-global.js"], ==, + expected != nullptr && strcmp(expected, "plantuml") == 0 ? 1 : 0); +} + +static void until(const std::function& predicate) { + const gint64 deadline = g_get_monotonic_time() + 20000000; + while (!predicate() && g_get_monotonic_time() < deadline) { + g_main_context_iteration(nullptr, FALSE); + g_usleep(1000); + } + g_assert_true(predicate()); +} + +static FlMethodResponse* decode(FlMethodCodec* codec, GBytes* bytes) { + g_autoptr(GError) error = nullptr; + auto* response = + FL_METHOD_CODEC_GET_CLASS(codec)->decode_response(codec, bytes, &error); + g_assert_no_error(error); + g_assert_nonnull(response); + return response; +} + +struct Harness { + TestMessenger* messenger; + FlStandardMethodCodec* codec; + GtkApplication* application; + FlView* view; + BusyMarkWebRenderHost* host; + Harness() { + contexts = views = harness_loads = 0; + resource_reads.clear(); + messenger = static_cast( + g_object_new(test_messenger_get_type(), nullptr)); + active_messenger = messenger; + codec = fl_standard_method_codec_new(); + application = gtk_application_new("io.busystack.busymark.nativeprobe", + G_APPLICATION_NON_UNIQUE); + g_assert_true( + g_application_register(G_APPLICATION(application), nullptr, nullptr)); + g_autoptr(FlDartProject) project = fl_dart_project_new(); + view = fl_view_new(project); + g_object_ref_sink(view); + host = busymark_web_render_host_new(application, nullptr); + busymark_web_render_host_register_channel(host, view); + dormant(); + } + void dormant() { + g_assert_cmpuint(contexts, ==, 0); + g_assert_cmpuint(views, ==, 0); + g_assert_cmpuint(harness_loads, ==, 0); + g_assert_null(host->context); + g_assert_null(host->web_view); + g_assert_null(host->offscreen_window); + g_assert_true(resource_reads.empty()); + } + ~Harness() { + busymark_web_render_host_shutdown(host); + until([&] { return host->active == nullptr; }); + BusyMarkWebRenderHost* remaining = host; + g_object_add_weak_pointer(G_OBJECT(host), + reinterpret_cast(&remaining)); + g_object_unref(host); + until([&] { return remaining == nullptr; }); + shutdown_messenger(FL_BINARY_MESSENGER(messenger)); + gtk_widget_destroy(GTK_WIDGET(view)); + g_object_unref(view); + g_object_unref(application); + g_object_unref(codec); + g_object_unref(messenger); + active_messenger = nullptr; + drain(); + } + TestResponse* call(const char* method, FlValue* args) { + auto* handler = static_cast( + g_hash_table_lookup(messenger->handlers, kMethods)); + g_assert_nonnull(handler); + g_autoptr(GError) error = nullptr; + auto* method_codec = FL_METHOD_CODEC(codec); + g_autoptr(GBytes) bytes = + FL_METHOD_CODEC_GET_CLASS(method_codec) + ->encode_method_call(method_codec, method, args, &error); + g_assert_no_error(error); + auto* response = static_cast( + g_object_new(test_response_get_type(), nullptr)); + handler->callback(FL_BINARY_MESSENGER(messenger), kMethods, bytes, + FL_BINARY_MESSENGER_RESPONSE_HANDLE(response), + handler->data); + return response; + } + FlValue* success(TestResponse* response) { + until([&] { return response->responded; }); + g_autoptr(FlMethodResponse) decoded = + decode(FL_METHOD_CODEC(codec), response->bytes); + if (FL_IS_METHOD_ERROR_RESPONSE(decoded)) { + auto* e = FL_METHOD_ERROR_RESPONSE(decoded); + g_error("%s: %s", fl_method_error_response_get_code(e), + fl_method_error_response_get_message(e)); + } + g_assert_true(FL_IS_METHOD_SUCCESS_RESPONSE(decoded)); + return fl_value_ref(fl_method_success_response_get_result( + FL_METHOD_SUCCESS_RESPONSE(decoded))); + } + void error(TestResponse* response, const char* code) { + until([&] { return response->responded; }); + g_autoptr(FlMethodResponse) decoded = + decode(FL_METHOD_CODEC(codec), response->bytes); + g_assert_true(FL_IS_METHOD_ERROR_RESPONSE(decoded)); + g_assert_cmpstr( + fl_method_error_response_get_code(FL_METHOD_ERROR_RESPONSE(decoded)), + ==, code); + } +}; + +constexpr char kApi[] = + R"({"openapi":"3.0.3","info":{"title":"First reference","version":"1"},"paths":{"/health":{"get":{"responses":{"200":{"description":"Healthy"}}}}}})"; +constexpr char kSvg[] = + R"()"; +static FlValue* args(const char* operation, const char* id = "first") { + g_autoptr(FlJsonMessageCodec) codec = fl_json_message_codec_new(); + std::string json; + if (strcmp(operation, "renderMathBatch") == 0) { + json = + R"({"expressions":[{"id":"one","expression":"x^2","display":true}]})"; + } else if (strcmp(operation, "renderPlantUml") == 0) { + json = + R"({"source":"@startuml\nAlice -> Bob: First\n@enduml","theme":"light"})"; + } else if (strcmp(operation, "renderMermaid") == 0) { + json = R"({"source":"graph LR; First-->Output","theme":"light"})"; + } else { + json = "{}"; + } + FlValue* result = fl_json_message_codec_decode(codec, json.c_str(), nullptr); + g_assert_nonnull(result); + fl_value_set_string_take(result, "requestId", fl_value_new_string(id)); + if (strstr(operation, "OpenApi") != nullptr) { + fl_value_set_string_take(result, "source", fl_value_new_string(kApi)); + fl_value_set_string_take(result, "entryId", + fl_value_new_string("first.json")); + fl_value_set_string_take(result, "title", + fl_value_new_string("First reference")); + fl_value_set_string_take(result, "theme", fl_value_new_string("light")); + } + if (strcmp(operation, "rasterizeSvg") == 0) { + fl_value_set_string_take(result, "svg", fl_value_new_string(kSvg)); + fl_value_set_string_take(result, "width", fl_value_new_float(120)); + fl_value_set_string_take(result, "height", fl_value_new_float(80)); + fl_value_set_string_take(result, "scale", fl_value_new_float(1)); + } + return result; +} +static void verify(const char* operation, FlValue* result) { + if (strcmp(operation, "rasterizeSvg") == 0) { + g_assert_cmpint(fl_value_get_type(result), ==, FL_VALUE_TYPE_UINT8_LIST); + g_autoptr(GdkPixbufLoader) loader = + gdk_pixbuf_loader_new_with_type("png", nullptr); + g_assert_true( + gdk_pixbuf_loader_write(loader, fl_value_get_uint8_list(result), + fl_value_get_length(result), nullptr)); + g_assert_true(gdk_pixbuf_loader_close(loader, nullptr)); + GdkPixbuf* pixbuf = gdk_pixbuf_loader_get_pixbuf(loader); + g_assert_cmpint(gdk_pixbuf_get_width(pixbuf), ==, 120); + g_assert_cmpint(gdk_pixbuf_get_height(pixbuf), ==, 80); + const auto* pixels = gdk_pixbuf_get_pixels(pixbuf); + const int channels = gdk_pixbuf_get_n_channels(pixbuf); + g_assert_cmpint(pixels[0], >, 200); + g_assert_cmpint(pixels[100 * channels + 2], >, 200); + } else if (strcmp(operation, "renderMathBatch") == 0) { + FlValue* results = fl_value_lookup_string(result, "results"); + g_assert_cmpuint(fl_value_get_length(results), ==, 1); + FlValue* svg = + fl_value_lookup_string(fl_value_get_list_value(results, 0), "svg"); + g_assert_nonnull(svg); + g_assert_nonnull(strstr(fl_value_get_string(svg), "responded); + g_autoptr(TestResponse) invalid_render = h.call("renderMermaid", nullptr); + h.error(invalid_render, "visualization.invalidArguments"); + g_autoptr(TestResponse) invalid = h.call("copyVisualizationImage", nullptr); + h.error(invalid, "visualization.invalidArguments"); + g_autoptr(FlValue) too_large = args("renderMermaid"); + std::string large(kMaximumRequestBytes + 1, 'x'); + fl_value_set_string_take(too_large, "source", + fl_value_new_string(large.c_str())); + g_autoptr(TestResponse) rejected = h.call("renderMermaid", too_large); + h.error(rejected, "visualization.sourceTooLarge"); + g_autoptr(GdkPixbuf) pixbuf = + gdk_pixbuf_new(GDK_COLORSPACE_RGB, FALSE, 8, 2, 2); + gdk_pixbuf_fill(pixbuf, 0xff0000ff); + gchar* png = nullptr; + gsize length = 0; + g_assert_true(gdk_pixbuf_save_to_buffer(pixbuf, &png, &length, "png", + nullptr, nullptr)); + g_autoptr(FlValue) clipboard = fl_value_new_map(); + fl_value_set_string_take( + clipboard, "png", + fl_value_new_uint8_list(reinterpret_cast(png), length)); + g_free(png); + g_autoptr(TestResponse) copied = + h.call("copyVisualizationImage", clipboard); + g_autoptr(FlValue) copy_result = h.success(copied); + g_autoptr(GdkPixbuf) readback = gtk_clipboard_wait_for_image( + gtk_clipboard_get(GDK_SELECTION_CLIPBOARD)); + g_assert_nonnull(readback); + h.dormant(); + } else if (strcmp(mode, "shutdown-channel") == 0) { + busymark_web_render_host_shutdown(h.host); + g_autoptr(FlValue) request = args("renderMermaid"); + g_autoptr(TestResponse) response = h.call("renderMermaid", request); + h.error(response, "visualization.hostUnavailable"); + h.dormant(); + } else if (strcmp(mode, "reference-shutdown") == 0) { + g_autoptr(FlValue) request = args("openOpenApiReference"); + g_autoptr(TestResponse) response = h.call("openOpenApiReference", request); + g_autoptr(FlValue) result = h.success(response); + busymark_web_render_host_shutdown(h.host); + drain(); + g_assert_null(h.host->reference_windows); + g_assert_null(h.host->context); + } else if (strcmp(mode, "openOpenApiReference") == 0) { + g_autoptr(FlValue) request = args(mode); + g_autoptr(TestResponse) response = h.call(mode, request); + g_autoptr(FlValue) result = h.success(response); + g_assert_cmpuint(contexts, ==, 1); + g_assert_null(h.host->web_view); + g_assert_null(h.host->offscreen_window); + g_assert_cmpuint(views, ==, 1); + g_assert_cmpuint(harness_loads, ==, 0); + g_assert_nonnull(h.host->reference_windows); + auto* window = GTK_WIDGET(h.host->reference_windows->data); + auto* view = WEBKIT_WEB_VIEW(gtk_bin_get_child(GTK_BIN(window))); + until([&] { return !webkit_web_view_is_loading(view); }); + // Verify actual Scalar DOM, rather than only the window-opening response. + bool finished = false; + webkit_web_view_call_async_javascript_function( + view, + "return await new Promise(resolve => { const poll = () => " + "document.body.innerText.includes('Healthy') ? resolve(true) : " + "setTimeout(poll, 20); poll(); });", + -1, nullptr, nullptr, nullptr, nullptr, + [](GObject* object, GAsyncResult* result, gpointer data) { + g_autoptr(GError) error = nullptr; + g_autoptr(JSCValue) value = + webkit_web_view_call_async_javascript_function_finish( + WEBKIT_WEB_VIEW(object), result, &error); + g_assert_no_error(error); + g_assert_nonnull(value); + *static_cast(data) = true; + }, + &finished); + until([&] { return finished; }); + // WebKit's platform default may itself be NEVER. Reference windows must + // retain that default rather than inherit the offscreen override. + g_autoptr(WebKitSettings) defaults = create_restricted_settings(); + g_assert_cmpint(webkit_settings_get_hardware_acceleration_policy( + webkit_web_view_get_settings(view)), + ==, + webkit_settings_get_hardware_acceleration_policy(defaults)); + only_engine("openapi"); + g_autoptr(FlValue) render = args("renderMermaid"); + g_autoptr(TestResponse) rendered = h.call("renderMermaid", render); + g_autoptr(FlValue) rendered_result = h.success(rendered); + verify("renderMermaid", rendered_result); + g_assert_cmpuint(contexts, ==, 1); + g_assert_cmpuint(views, ==, 2); + g_assert_cmpuint(harness_loads, ==, 1); + } else { + g_autoptr(FlValue) request = args("renderMermaid"); + const gint64 started = g_get_monotonic_time(); + if (is_render_operation(mode)) { + g_clear_pointer(&request, fl_value_unref); + request = args(mode); + g_autoptr(TestResponse) response = h.call(mode, request); + g_assert_cmpuint(contexts, ==, 0); + g_autoptr(FlValue) result = h.success(response); + verify(mode, result); + only_engine(strcmp(mode, "renderMermaid") == 0 ? "mermaid" + : strcmp(mode, "renderPlantUml") == 0 ? "plantuml" + : strcmp(mode, "renderMathBatch") == 0 ? "math" + : strstr(mode, "OpenApi") != nullptr ? "openapi" + : nullptr); + g_assert_cmpuint(contexts, ==, 1); + std::printf("first-use %s duration_us=%" G_GINT64_FORMAT "\n", mode, + g_get_monotonic_time() - started); + g_autoptr(FlValue) repeat = args(mode, "second"); + g_autoptr(TestResponse) repeated = h.call(mode, repeat); + g_autoptr(FlValue) repeated_result = h.success(repeated); + verify(mode, repeated_result); + g_assert_cmpuint(resource_reads["render-engines.js"], ==, 1); + only_engine(strcmp(mode, "renderMermaid") == 0 ? "mermaid" + : strcmp(mode, "renderPlantUml") == 0 ? "plantuml" + : strcmp(mode, "renderMathBatch") == 0 ? "math" + : strstr(mode, "OpenApi") != nullptr ? "openapi" + : nullptr); + g_assert_cmpuint(views, ==, 1); + g_assert_cmpuint(harness_loads, ==, 1); + g_assert_true(webkit_web_context_is_ephemeral(h.host->context)); + g_assert_true(webkit_web_context_get_sandbox_enabled(h.host->context)); + auto* settings = webkit_web_view_get_settings(h.host->web_view); + g_assert_false(webkit_settings_get_enable_html5_local_storage(settings)); + g_assert_false(webkit_settings_get_enable_html5_database(settings)); + g_assert_cmpint( + webkit_settings_get_hardware_acceleration_policy(settings), ==, + WEBKIT_HARDWARE_ACCELERATION_POLICY_NEVER); + g_assert_cmpuint(contexts, ==, 1); + } else if (strcmp(mode, "engine-failure") == 0 || + strcmp(mode, "graphviz-failure") == 0) { + const char* operation = strcmp(mode, "graphviz-failure") == 0 + ? "renderPlantUml" + : "renderMermaid"; + failed_engine_resource = strcmp(mode, "graphviz-failure") == 0 + ? "viz-global.js" + : "mermaid-engine.js"; + fail_engine_read = true; + g_clear_pointer(&request, fl_value_unref); + request = args(operation); + g_autoptr(TestResponse) first = h.call(operation, request); + g_autoptr(FlValue) math = args("renderMathBatch", "math"); + g_autoptr(TestResponse) second = h.call("renderMathBatch", math); + h.error(first, "visualization.hostUnavailable"); + g_autoptr(FlValue) math_result = h.success(second); + verify("renderMathBatch", math_result); + // Retry without the release-only process-termination hook. A failed + // module registry must not permanently poison this renderer operation. + g_autoptr(TestResponse) retry = h.call(operation, request); + g_autoptr(FlValue) result = h.success(retry); + verify(operation, result); + g_assert_cmpuint(contexts, ==, 1); + g_assert_cmpuint(harness_loads, ==, 2); + g_assert_cmpuint(views, ==, 2); + } else if (strcmp(mode, "failure") == 0 || + strcmp(mode, "load-failure") == 0 || + strcmp(mode, "harness-failure") == 0) { + if (strcmp(mode, "failure") == 0) { + fail_context = true; + } else { + g_free(h.host->resource_root); + if (strcmp(mode, "harness-failure") == 0) { + h.host->resource_root = + g_dir_make_tmp("bm-harness-failure-XXXXXX", nullptr); + g_autofree gchar* path = + g_build_filename(h.host->resource_root, "harness.html", nullptr); + g_assert_true(g_file_set_contents( + path, "Missing engine", -1, nullptr)); + } else { + h.host->resource_root = g_strdup("/nonexistent/busymark-probe"); + } + } + g_autoptr(TestResponse) first = h.call("renderMermaid", request); + g_autoptr(TestResponse) second = h.call("renderMermaid", request); + h.error(first, "visualization.hostUnavailable"); + h.error(second, "visualization.hostUnavailable"); + until([&] { return h.host->recreate_source_id == 0; }); + g_assert_null(h.host->web_view); + g_assert_null(h.host->context); + g_free(h.host->resource_root); + h.host->resource_root = locate_resource_root(); + g_autoptr(TestResponse) retry = h.call("renderMermaid", request); + g_autoptr(FlValue) result = h.success(retry); + verify("renderMermaid", result); + } else if (strcmp(mode, "concurrent-cancel") == 0 || + strcmp(mode, "pre-cancel") == 0) { + g_autoptr(TestResponse) first = h.call("renderMermaid", request); + if (strcmp(mode, "concurrent-cancel") == 0) { + until([&] { return h.host->state == RenderState::loading; }); + } + g_autoptr(FlValue) cancel = args("cancelRender"); + g_autoptr(TestResponse) cancelled = h.call("cancelRender", cancel); + g_autoptr(FlValue) result = h.success(cancelled); + g_assert_true(fl_value_get_bool(result)); + h.error(first, "visualization.cancelled"); + if (strcmp(mode, "pre-cancel") == 0) { + until([&] { return h.host->recreate_source_id == 0; }); + h.dormant(); + } else { + g_autoptr(FlValue) math = args("renderMathBatch", "math"); + g_autoptr(FlValue) plant = args("renderPlantUml", "plant"); + g_autoptr(TestResponse) second = h.call("renderMathBatch", math); + g_autoptr(TestResponse) third = h.call("renderPlantUml", plant); + g_autoptr(FlValue) math_result = h.success(second); + g_autoptr(FlValue) plant_result = h.success(third); + verify("renderMathBatch", math_result); + verify("renderPlantUml", plant_result); + g_assert_cmpuint(contexts, ==, 1); + g_assert_cmpuint(views, ==, 1); + g_assert_cmpuint(harness_loads, ==, 1); + } + } else if (strcmp(mode, "active-cancel") == 0 || + strcmp(mode, "snapshot-cancel") == 0 || + strcmp(mode, "engine-cancel") == 0) { + if (strcmp(mode, "snapshot-cancel") == 0) { + g_clear_pointer(&request, fl_value_unref); + request = args("rasterizeSvg"); + } + g_autoptr(TestResponse) first = + h.call(strcmp(mode, "snapshot-cancel") == 0 ? "rasterizeSvg" + : "renderMermaid", + request); + until([&] { + return h.host->active != nullptr && + (strcmp(mode, "snapshot-cancel") != 0 || + h.host->active->snapshot_wait_source_id != 0); + }); + if (strcmp(mode, "engine-cancel") == 0) + until([&] { return resource_reads["mermaid-engine.js"] != 0; }); + g_autoptr(FlValue) cancel = args("cancelRender"); + g_autoptr(TestResponse) cancelled = h.call("cancelRender", cancel); + h.error(first, "visualization.cancelled"); + until([&] { return h.host->active == nullptr; }); + g_clear_pointer(&request, fl_value_unref); + request = args("renderMermaid"); + g_autoptr(TestResponse) repeated = h.call("renderMermaid", request); + g_autoptr(FlValue) result = h.success(repeated); + verify("renderMermaid", result); + } else if (strstr(mode, "shutdown-") == mode) { + g_autoptr(TestResponse) first = h.call("renderMermaid", request); + if (strcmp(mode, "shutdown-loading") == 0) + until([&] { return h.host->state == RenderState::loading; }); + if (strcmp(mode, "shutdown-readiness") == 0) + until([&] { return h.host->initialization_cancellable != nullptr; }); + if (strcmp(mode, "shutdown-active") == 0) + until([&] { return h.host->active != nullptr; }); + if (strcmp(mode, "shutdown-engine") == 0) + until([&] { return resource_reads["mermaid-engine.js"] != 0; }); + busymark_web_render_host_shutdown(h.host); + h.error(first, "visualization.hostUnavailable"); + until([&] { return h.host->active == nullptr; }); + drain(); + g_assert_null(h.host->context); + g_assert_null(h.host->web_view); + g_assert_true(h.host->state == RenderState::stopped); + } else if (strcmp(mode, "recovery") == 0) { + g_autoptr(TestResponse) first = h.call("renderMermaid", request); + g_autoptr(FlValue) result = h.success(first); + g_setenv("BUSYMARK_RELEASE_SMOKE", "1", TRUE); + g_autoptr(TestResponse) recovery = + h.call("terminateWebProcessForReleaseSmoke", nullptr); + g_autoptr(FlValue) recovered = h.success(recovery); + g_autoptr(TestResponse) repeated = h.call("renderMermaid", request); + g_autoptr(FlValue) repeat = h.success(repeated); + verify("renderMermaid", repeat); + g_assert_cmpuint(views, ==, 2); + g_assert_cmpuint(harness_loads, ==, 2); + g_assert_cmpint(webkit_settings_get_hardware_acceleration_policy( + webkit_web_view_get_settings(h.host->web_view)), + ==, WEBKIT_HARDWARE_ACCELERATION_POLICY_NEVER); + g_assert_cmpuint(contexts, ==, 1); + } else { + g_error("Unknown probe %s", mode); + } + } + std::printf("PASS %s\n", mode); +} diff --git a/tools/writerside_toc_visual_smoke.dart b/tools/writerside_toc_visual_smoke.dart index 8f5de025..65642611 100644 --- a/tools/writerside_toc_visual_smoke.dart +++ b/tools/writerside_toc_visual_smoke.dart @@ -463,7 +463,7 @@ class _HarnessState extends ConsumerState<_Harness> { _check( await _native( 'has', - 'Used for the current instance only. By default, inherited from topic title.', + 'The pinned builder does not publish instance title overrides in Markdown topics. Existing markup is preserved; inspect it in Source.', ) == 'true' && await _native( diff --git a/tools/wysiwyg_selection_smoke.dart b/tools/wysiwyg_selection_smoke.dart index 623fe2e0..11c48405 100644 --- a/tools/wysiwyg_selection_smoke.dart +++ b/tools/wysiwyg_selection_smoke.dart @@ -488,9 +488,9 @@ class _ProbeState extends State<_Probe> { '02-document-select-all', expectedRange: globalRange, needles: const [ - '/mnt/Media/Linguality/gateway', - '/mnt/Media/Linguality/learner_client', - 'not the final integration result.', + '/opt/example/gateway', + '/opt/example/desktop_client', + 'must preserve its final punctuation.', ], ); await _key(PhysicalKeyboardKey.keyA, LogicalKeyboardKey.keyA); @@ -502,7 +502,7 @@ class _ProbeState extends State<_Probe> { await _key(PhysicalKeyboardKey.keyA, LogicalKeyboardKey.keyA); final paragraphIndex = blocks.indexWhere( (b) => b.plainText.contains( - 'The client must work before corpus population finishes.', + 'The editor preserves selection across paragraph boundaries.', ), ); await _jump(paragraphIndex - 1); @@ -547,7 +547,7 @@ class _ProbeState extends State<_Probe> { ), ); final listIndex = blocks.indexWhere( - (b) => b.plainText == '/mnt/Media/Linguality/gateway', + (b) => b.plainText == '/opt/example/gateway', ); await _jump(listIndex - 1); await _drag( @@ -565,8 +565,8 @@ class _ProbeState extends State<_Probe> { blocks[listIndex + 2].plainText.length, ), needles: [ - '/mnt/Media/Linguality/gateway', - '/mnt/Media/Linguality/learner_client', + '/opt/example/gateway', + '/opt/example/desktop_client', blocks[listIndex + 2].plainText, ], ); @@ -618,8 +618,8 @@ class _ProbeState extends State<_Probe> { ), needles: [ paragraph.plainText, - '/mnt/Media/Linguality/gateway', - '/mnt/Media/Linguality/learner_client', + '/opt/example/gateway', + '/opt/example/desktop_client', for (final cell in cells) cell.plainText, ], ); From 0367026579c5b275cf4583c377e3366264113368 Mon Sep 17 00:00:00 2001 From: albert Date: Thu, 15 Oct 2026 00:20:15 -0700 Subject: [PATCH 4/4] Coalesce offline reconciliations and await active runs on disposal --- .../application/notes_offline_controller.dart | 101 ++++++++------- .../notes_workspace_workflows_test.dart | 115 ++++++++++++++++++ 2 files changed, 175 insertions(+), 41 deletions(-) diff --git a/lib/src/nextcloud_notes/application/notes_offline_controller.dart b/lib/src/nextcloud_notes/application/notes_offline_controller.dart index 95fd185e..ea2f2c83 100644 --- a/lib/src/nextcloud_notes/application/notes_offline_controller.dart +++ b/lib/src/nextcloud_notes/application/notes_offline_controller.dart @@ -38,6 +38,7 @@ class NotesOfflineController { final _attempted = {}; final failures = {}; Timer? _debounce; + Future? _reconciliation, _disposing; bool _disposed = false, _running = false, _again = false; int _generation = 0; Future initialize() async { @@ -157,48 +158,64 @@ class NotesOfflineController { ); } - Future reconcile() async { - if (_disposed) return; - if (_running) { + Future reconcile() { + if (_disposed) return Future.value(); + _debounce?.cancel(); + if (_reconciliation case final pending?) { _again = true; - return; + return pending; } + return _reconciliation = _reconcile().whenComplete( + () => _reconciliation = null, + ); + } + + Future _reconcile() async { _running = true; - final generation = _generation; try { - for (final note in repository.notes.where( - (n) => n.accountId == accountId && retained(n) && !isNotesRecovery(n), - )) { - if (_disposed || repository.accountById(accountId) == null) return; - var status = await inspect(note); - statuses[note.localId] = status; - _emit(); - if (_active(note)) { - for (final reference in status.missing) { - if (_disposed || generation != _generation || !_active(note)) break; - final key = '${note.localId}:${note.revision}:$reference'; - if (!_attempted.add(key)) continue; - try { - final path = await repository.resolveMedia( - accountId, - note.localId, - reference, - ); - if (path == null) failures[key] = 'Attachment unavailable'; - } on Object catch (error) { - failures[key] = error.toString(); - } - if (repository.accountById(accountId) == null) return; - final current = repository.noteById(note.localId); - if (current != null) { - status = await inspect(current); - statuses[note.localId] = status; - _emit(); + // Coalesced callers await both the active pass and their requested pass. + do { + _again = false; + final generation = _generation; + for (final note in repository.notes.where( + (n) => n.accountId == accountId && retained(n) && !isNotesRecovery(n), + )) { + if (_disposed || repository.accountById(accountId) == null) return; + var status = await inspect(note); + if (_disposed) return; + statuses[note.localId] = status; + _emit(); + if (_active(note)) { + for (final reference in status.missing) { + if (_disposed || generation != _generation || !_active(note)) { + break; + } + final key = '${note.localId}:${note.revision}:$reference'; + if (!_attempted.add(key)) continue; + try { + final path = await repository.resolveMedia( + accountId, + note.localId, + reference, + ); + if (path == null) failures[key] = 'Attachment unavailable'; + } on Object catch (error) { + failures[key] = error.toString(); + } + if (_disposed || repository.accountById(accountId) == null) { + return; + } + final current = repository.noteById(note.localId); + if (current != null) { + status = await inspect(current); + statuses[note.localId] = status; + _emit(); + } } } } - } - statuses.removeWhere((id, _) => repository.noteById(id) == null); + statuses.removeWhere((id, _) => repository.noteById(id) == null); + } while (_again && !_disposed); } finally { _running = false; for (final entry in statuses.entries.toList()) { @@ -214,10 +231,6 @@ class NotesOfflineController { ); } _emit(); - if (_again && !_disposed) { - _again = false; - _schedule(); - } } } @@ -225,7 +238,9 @@ class NotesOfflineController { if (!_disposed) _changes.add(null); } - Future dispose() async { + Future dispose() => _disposing ??= _dispose(); + + Future _dispose() async { _disposed = true; _generation++; _debounce?.cancel(); @@ -235,6 +250,10 @@ class NotesOfflineController { repository.cancelMediaDownloads(note.localId); } await _subscription.cancel(); - await _changes.close(); + try { + await _reconciliation; + } finally { + await _changes.close(); + } } } diff --git a/test/src/nextcloud_notes/notes_workspace_workflows_test.dart b/test/src/nextcloud_notes/notes_workspace_workflows_test.dart index 5d19b31b..1277c4a8 100644 --- a/test/src/nextcloud_notes/notes_workspace_workflows_test.dart +++ b/test/src/nextcloud_notes/notes_workspace_workflows_test.dart @@ -128,6 +128,35 @@ class _Fixture { } } +class _HeldInspectionOfflineController extends NotesOfflineController { + _HeldInspectionOfflineController(super.repository, super.accountId); + + final inspectionStarted = Completer(); + final releaseInspection = Completer(); + Completer? reconciliationRequested; + + @override + Future reconcile() { + final result = super.reconcile(); + reconciliationRequested?.complete(); + reconciliationRequested = null; + return result; + } + + @override + Future inspect( + NextcloudNote note, { + String? source, + }) async { + final status = await super.inspect(note, source: source); + if (!inspectionStarted.isCompleted) { + inspectionStarted.complete(); + await releaseInspection.future; + } + return status; + } +} + void main() { late _Fixture f; setUp(() async { @@ -1538,6 +1567,7 @@ void main() { await f.repository.synchronize(testAccount().id, allowWrites: false); final note = f.repository.notes.single; var offline = NotesOfflineController(f.repository, testAccount().id); + addTearDown(() => offline.dispose()); await offline.initialize(); await offline.cancelNote(note.localId); await offline.setRequirement('category', 'Work'); @@ -1556,6 +1586,91 @@ void main() { await offline.dispose(); }, ); + for (final resumeWithRetry in [true, false]) { + test( + '${resumeWithRetry ? 'retry' : 'requirement change'} waits for an active offline reconciliation', + () async { + f.remote[1] = { + ...serverNote(1), + 'category': 'Work/Sub', + 'content': '![image](.attachments.1/a.png)', + }; + await f.repository.synchronize(testAccount().id, allowWrites: false); + final note = f.repository.notes.single; + await f.repository.setOfflineRequirement( + testAccount().id, + 'category', + 'Work', + ); + await f.repository.setOfflineRequirement( + testAccount().id, + 'pause', + note.localId, + paused: true, + ); + final offline = _HeldInspectionOfflineController( + f.repository, + testAccount().id, + ); + addTearDown(() async { + if (!offline.releaseInspection.isCompleted) { + offline.releaseInspection.complete(); + } + await offline.dispose(); + }); + await offline.initialize(); + await offline.inspectionStarted.future; + final requested = Completer(); + offline.reconciliationRequested = requested; + var completed = false; + final resumed = + (resumeWithRetry + ? offline.retry(note.localId) + : offline.setRequirement( + 'pause', + note.localId, + required: false, + )) + .then((_) => completed = true); + await requested.future; + // Flush completion callbacks while the first inspection is held. + await Future.delayed(Duration.zero); + expect(completed, false); + expect(f.downloads, 0); + offline.releaseInspection.complete(); + await resumed; + expect((await offline.inspect(note)).available, true); + expect(f.downloads, 1); + }, + ); + } + test('offline disposal waits for an active inspection', () async { + await f.seed(); + await f.repository.setOfflineRequirement( + testAccount().id, + 'category', + 'Work', + ); + final offline = _HeldInspectionOfflineController( + f.repository, + testAccount().id, + ); + addTearDown(() async { + if (!offline.releaseInspection.isCompleted) { + offline.releaseInspection.complete(); + } + await offline.dispose(); + }); + await offline.initialize(); + await offline.inspectionStarted.future; + var disposed = false; + final disposal = offline.dispose().then((_) => disposed = true); + await Future.delayed(Duration.zero); + expect(disposed, false); + offline.releaseInspection.complete(); + await disposal; + await f.restart(); + }); test( 'captured export rejects refreshed media but preserves captured text during later edits', () async {