diff --git a/.gitignore b/.gitignore index 6a125821..9099c2b2 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,9 @@ migrate_working_dir/ .pub/ /build/ /coverage/ +/delivery/ +/test-results/ +/evidence/ .widget_preview/ **/node_modules/ linux/flutter/ephemeral/ diff --git a/README.md b/README.md index e0cd2a22..7b710201 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # BusyMark -Markdown and Writerside editor for Linux. +Markdown, Writerside, and Nextcloud Notes editor for Linux. [![busymark](https://snapcraft.io/busymark/badge.svg)](https://snapcraft.io/busymark) @@ -14,10 +14,11 @@ Markdown and Writerside 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. -* **Project navigation and search** — Files, Topics, and Outline views, tabbed documents, command palette, keyboard shortcuts, document search and replace, reviewed workspace-wide replacement, and Markdown TOC generation. +* **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. +* **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. -* **Git integration** — Review changes and diffs, stage and unstage files, commit, create and switch branches, fetch, pull, push, inspect file and project history, compare historical versions, and restore earlier file versions. +* **Git integration for local workspaces** — Review changes and diffs, stage and unstage files, commit, create and switch branches, fetch, pull, push, inspect file and project history, compare historical versions, and restore earlier file versions. * **Clipboard and Local History** — Reuse source, rich-text, and image fragments during the current session; compare and restore persistent on-device document revisions independently of Git. * **AI-assisted editing** — Optional Ollama, OpenAI, and Gemini integration with explicit edit scope and shared context, proposal review before changes are applied, and AI-assisted Git commit-message drafting. * **Workspace reliability** — Detect files changed outside BusyMark, recover unsaved documents, restore previous workspace sessions, manage remote-image permissions, and protect Git operations behind workspace trust. @@ -186,10 +187,38 @@ More information: * [Admonitions](docs/admonitions.md) * [Collapsible elements](docs/collapsible-elements.md) +## Nextcloud Notes + +BusyMark can open a **Nextcloud Notes** workspace alongside its local Markdown and Writerside workflows. Notes stay associated with your Nextcloud account rather than a folder on your computer. + +### Connect your account + +1. Ensure the **Notes** app is enabled on your Nextcloud server. +2. On BusyMark’s welcome screen, choose **Nextcloud Notes**. You can also open **Settings → Nextcloud Notes**. +3. Enter the full **HTTPS** address of your Nextcloud server and click **Connect**. +4. Complete authorization in your default web browser. BusyMark verifies Notes compatibility and opens the note workspace. + +Once connected, select **Nextcloud Notes** on the welcome screen to reopen the workspace. Use **Settings → Nextcloud Notes** to open it, reconnect, or disconnect the account. **One Nextcloud account can be connected at a time.** + +### 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. +* **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. + +### Requirements and limitations + +* BusyMark requires a server advertising **Nextcloud Notes API 1.4 or later in API major version 1**. Notes 6.0.x and 6.1.x are supported baselines. **Deleting attachments** requires Notes 6.1.0 or newer. +* Connections require HTTPS and certificates trusted by the operating system. Authentication uses Nextcloud’s browser-based Login Flow; the resulting app password is stored in the Linux keyring (libsecret), not in the notes database. + ## Search and replace BusyMark supports active-document replacement and reviewed workspace-wide -replacement. See [Search and replace](docs/search-and-replace.md) for search +replacement for local workspaces. Nextcloud Notes supports replacement within +individual notes, but not workspace-wide replacement. See [Search and replace](docs/search-and-replace.md) for search options, scope, preview behavior, and stale-result handling. ## Diagrams and mathematics @@ -203,7 +232,7 @@ See: ## Export -BusyMark can export the current Markdown document or a Writerside instance to PDF and portable offline HTML. +BusyMark can export the current Markdown document (including a Nextcloud note) or a Writerside instance to PDF and portable offline HTML. PDF export supports configurable paper size, orientation, margins, typography, headers and footers, page numbering, table of contents, and heading numbering. @@ -232,7 +261,7 @@ undo, crash recovery, and Git. Implementation and verification details are in Issues and focused pull requests are welcome. -Changes should remain consistent with BusyMark's current scope as a desktop Markdown and Writerside documentation editor. +Changes should remain consistent with BusyMark's scope as a Linux desktop editor for Markdown, Writerside documentation, and Nextcloud Notes. ## License diff --git a/docs/README.md b/docs/README.md index 674884f6..af072f65 100644 --- a/docs/README.md +++ b/docs/README.md @@ -35,5 +35,6 @@ are intentionally independent so they can be referenced directly. - [AI implementation and qualification](development/ai.md) - [Export verification](development/export-verification.md) - [History implementation](development/history.md) +- [Nextcloud Notes architecture, API contract, and verification](development/nextcloud-notes.md) - [Visualization implementation and verification](development/visualizations.md) - [Writerside verification](development/writerside-verification.md) diff --git a/docs/development/export-verification.md b/docs/development/export-verification.md index 57bac76e..e1fcfef8 100644 --- a/docs/development/export-verification.md +++ b/docs/development/export-verification.md @@ -109,7 +109,7 @@ artifacts to a fresh temporary directory: verification_dir="$(mktemp -d)" report="$verification_dir/report.json" BUSYMARK_RELEASE_SMOKE=1 \ - xvfb-run -a env WEBKIT_DISABLE_COMPOSITING_MODE=1 \ + xvfb-run -a env GDK_BACKEND=x11 \ LIBGL_ALWAYS_SOFTWARE=1 build/linux/x64/release/bundle/busymark \ --visualization-release-smoke="$report" ``` @@ -141,6 +141,6 @@ do not certify physical-printer or assistive-technology behavior. ## CI environment The Linux workflow in `.github/workflows/flutter-linux.yml` is authoritative for -native packages, environment flags, browser setup, X11 and Wayland runs, and the +native packages, environment flags, X11 and Wayland runs, and the strict Snap verification. Keep ordinary build prerequisites in the main README; do not copy the larger CI-only dependency set into user installation steps. diff --git a/docs/development/history.md b/docs/development/history.md index fb338006..ab34f5fe 100644 --- a/docs/development/history.md +++ b/docs/development/history.md @@ -154,6 +154,10 @@ is accepted, so an older checkpoint cannot recreate erased revisions. ## Verification +Keep manual-probe outputs outside the repository, for example in directories +created with `mktemp -d`. Use disposable workspaces for probes that edit files, +and remove their temporary data after inspection. + Run the standard checks with the repository-pinned toolchain: ```bash @@ -189,3 +193,24 @@ GDK_BACKEND=x11 build/linux/x64/debug/bundle/busymark \ Under `xvfb-run`, set `GDK_BACKEND=x11` explicitly so an inherited `WAYLAND_DISPLAY` cannot select an unrelated Wayland connection. Clipboard behavior under Wayland requires a compositor-driven run with real input focus. + +The native selection probe covers pointer/keyboard selection, highlight +geometry, rich/plain clipboard content, table boundaries, editing, undo and +restored selection. It copies the maintained fixture before use and exercises +the production editor and Linux clipboard: + +```bash +selection_output="$(mktemp -d)" +flutter build linux --debug --no-pub --target tools/wysiwyg_selection_smoke.dart +GDK_BACKEND=x11 dbus-run-session -- \ + xvfb-run -a -s '-screen 0 1440x1000x24' \ + build/linux/x64/debug/bundle/busymark \ + test/fixtures/wysiwyg/selection_boundaries.md "$selection_output" +flutter build linux --debug --no-pub --target lib/main.dart +``` + +The probe exits nonzero on failed assertions and writes temporary captures and +JSON reports for inspection. Its optional `--application` mode opens a supplied +file in the full application and requires the exact synthetic table fixture +checked by the driver; use an isolated application profile and disposable files +for that mode. diff --git a/docs/development/nextcloud-notes-validation.md b/docs/development/nextcloud-notes-validation.md deleted file mode 100644 index 8cd1adf1..00000000 --- a/docs/development/nextcloud-notes-validation.md +++ /dev/null @@ -1,657 +0,0 @@ -# Nextcloud Notes implementation validation - -This record describes the checks actually executed for this implementation on -October 4–5, 2026 (UTC). See [the integration contract](nextcloud-notes.md) for the -architecture, security boundaries and server limitations. - -## Capability and synchronization follow-up (October 5, 2026) - -This follow-up addresses only API 1.4 compatibility, uncertain creation, -attribute-wise merging, HTTPS enforcement and bounded attachment downloads. The -working tree was clean before editing. The earlier review and implementation -results below are historical, not substitutes for the checks in this section. - -The current App Store stable release was **Notes 6.1.0** (Nextcloud 33–35). -Re-read the API README/v1, Application/Capabilities and routes at **v6.0.0, -v6.0.1, v6.0.2 and v6.1.0**, plus issue #2037. Exact commit IDs and the route -matrix are in [the contract](nextcloud-notes.md). All advertise API 1.4. The -connection gate now requires major 1/minor >=4; missing or malformed app versions -are accepted. App version controls only attachment deletion (known stable ->=6.1.0). Notes 6.0.x flat attachment upload filenames and 6.1.x scoped filenames -are both supported. - -No dependency or toolchain changes. SQLite user_version **1 → 2** fences the new -optional durable creation-attempt JSON in notes/outbox, preserving existing -records. The shared asset limit remains **100 MiB**. New scalar conflict UI text -was translated in all 23 ARB catalogs and generated using `flutter gen-l10n`. - -### Commands and results - -All Flutter/Dart commands use `/home/albert/flutter/bin/` (Flutter 3.47.5, -Dart 3.13.4). Logs and ephemeral reports use `/tmp/busymark-compat-*`. - -| Command/check | Result | -| --- | --- | -| `flutter pub get --enforce-lockfile` | Exit 0; lockfile unchanged | -| `flutter gen-l10n` | Exit 0 | -| `dart format --set-exit-if-changed .` | Exit 0; 542 files, zero changes | -| `flutter analyze` | Exit 0; no issues | -| Focused command below, including localization audit | Exit 0; **195 passed**, zero failed/skipped | -| Full Flutter suite below | Exit 1; **3,406 passed, 1 failed**, zero skipped; 15m20s. The same routing failure reproduces on untouched HEAD. | -| `(cd packages/busymark_spellcheck_native && dart pub get && dart test)` | Exit 0; **13 passed** | -| `tools/validate_writerside_conformance.sh` | Exit 0; **181 checks passed** | -| `flutter build linux --release` | Exit 0 | -| `DISPLAY=:97 GDK_BACKEND=x11 bash tools/test_gtk_accent.sh` | Exit 0 | -| `DISPLAY=:97 GDK_BACKEND=x11 bash tools/test_gtk_accent_host.sh` | Exit 0 | -| `bash tools/test_secure_credential_policy.sh` | Exit 0 | -| `bash tools/test_nextcloud_libsecret.sh` | Exit 0; isolated Secret Service create/read/delete | -| Release `--visualization-release-smoke` under X11 and Wayland | Both exit 0; both reports `ok: true`, including HTML/PDF | -| Release `--spelling-release-smoke` under X11 then Wayland | Both exit 0; install then restart with retained personal word; source/WYSIWYG/table edits passed | -| `tools/visualization_smoke.py` under X11 and Wayland | Both exit 0; real WebKit, PlantUML, Mermaid, OpenAPI, Scalar and D2 coverage | -| Exact-recipe Snapcraft build | Exit 0; strict core24 amd64 package, exact recipe/manifest, 8 artifact checks and unpacked dependency/resource probe passed | -| `git diff --check` | Exit 0 | - -```sh -flutter test --no-pub \ - test/src/nextcloud_notes \ - test/src/nextcloud_capabilities_test.dart \ - test/src/nextcloud_connection_test.dart \ - test/src/nextcloud_login_flow_test.dart \ - test/src/nextcloud_workspace_test.dart \ - test/src/nextcloud_notes_ui_test.dart \ - test/src/nextcloud_media_export_test.dart \ - test/src/localization_audit_test.dart --reporter expanded - -BUSYMARK_NATIVE_DIALOG_TEST_DISPLAY=:97 \ -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" \ -flutter test --no-pub --concurrency=1 --reporter expanded -``` - -The first full run was interrupted after two PDF tests failed while a concurrent -release build replaced their bundled Typst executable. The final full run used -stable build output. Its sole failure was `app_router_test.dart: returning to -Welcome does not restore the session again` (line 168: missing Welcome tooltip). -An isolated rerun failed identically. A `git archive HEAD` snapshot of -`366e303` under `/tmp`, -with fresh `.dart_tool`, `flutter pub get --enforce-lockfile`, and the same pinned -SDK, also failed the identical test (4 passed, 1 failed). This pre-existing -failure was left unchanged because this task explicitly excludes unrelated -fixes; the full suite is **not reported as passing**. Initial focused compile/fixture errors and brace diagnostics -were corrected before the final runs; no failing tests were suppressed. - -Native smoke checks used private Xvfb displays and unpacked Weston 13.0.0 with a -headless backend. The Python smoke script initially lacked `gi._gi_cairo`; the -matching Ubuntu Python Cairo packages were unpacked under `/tmp`, then the script -passed with that temporary PYTHONPATH. No installed packages or project pins were -changed. A minimal private D-Bus session avoided desktop portal interference. - -### Snap packaging evidence - -Built from a separate source copy with **official Snapcraft 8.11.1**: - -```sh -SNAPCRAFT_BUILD_INFO=1 CMAKE_BUILD_PARALLEL_LEVEL=4 snapcraft pack --destructive-mode --verbosity=brief -``` - -The official container needed its matching upstream extension resources and -`craftctl` entry point restored, an apt index refresh, the declared Node 24 snap -on PATH, and `/lib/python3.12/site-packages` on PYTHONPATH after apt installed the -system Python. Those were container-only infrastructure corrections. The exact -repository recipe, Flutter 3.47.5, dependencies and pins were unchanged. All 334 -Dart/pin/recipe files in the build source matched the working tree. Snapcraft -reported unused-library warnings; packing exited 0. - -- Artifact: `/tmp/busymark-compat-snap/source/busymark_0.5.1_amd64.snap` -- Size: **309,592,064 bytes** -- SHA-256: `648f854d7af18853bc49ff0837c2b64f53d64d57c74b6977aab2ea9259e96378` -- Exact recipe SHA-256: `af358e17aadbd4f816069eccd6d4656818d489443b17345cbe0ad78e481dc590` -- Native SQLite loaded successfully: **3.53.4**. -- Existing network/browser/keyring plugs and native plugins were present. -- The unpacked CI-equivalent dependency/resource probe passed for GNOME/Mesa - resolution, Git/OpenSSH, GStreamer, SVG loading, fonts/themes and RUNPATH. -- Artifact report: `/tmp/busymark-compat-snap/validation.json`; build and probe - logs are retained beside it. This proves packaging, not installed confinement. - -### Live acceptance - -The live harness uses verified **HTTPS** with a localhost certificate trusted by -an explicitly injected test-only SecurityContext. It does not disable certificate -or hostname verification, and does not add any production HTTP exception. - -```sh -dart run tools/nextcloud_notes_live_acceptance.dart \ - /tmp/busymark-compat-live/credentials.json \ - /tmp/busymark-compat-live/notes-VERSION-report.json \ - /tmp/busymark-compat-live/cert.pem -``` - -Two isolated **Nextcloud 35.0.1** installations used the official App Store release -packages for **Notes 6.0.1 and 6.1.0**. Both completed **13 checks successfully**. Both exercised: - -- OCS compatibility acceptance and API 1.4, with no obsolete-app rejection. -- Durable creation, list/chunks/Last-Modified, ETags/304, edit, category, favorite, - server title sanitization, and a second actor's conflict plus merge. -- Attachment upload, authoritative renamed filename and streamed download. -- Offline new note plus attachment, database close/reopen, reconnect/publication. -- Remote deletion with pending work and explicit fresh-state-checked deletion. -- A deliberately lost successful POST response, durable wire metadata and staged - attachment across restart, another local edit, automatic candidate adoption, - sanitized title/category adoption, attachment publication and **one note POST**. - -On 6.0.1, invoking attachment DELETE produced `unsupported` without issuing the -request and the other operations continued. On 6.1.0, DELETE succeeded. The -harness's original 304 assertion mixed ETags from different list pages; it was -corrected to use a consistent unchunked representation for 304, while separately -checking real multi-chunk behavior. - -Production HTTP rejection, known/unknown/incorrect attachment Content-Length, -100 MiB boundary enforcement, interrupted/cancelled streams, traversal and -cross-origin redirects were exercised by deterministic injected-client tests. -The local/private server URL supplied in the original failure was not available; -6.0.1 was tested in a fresh disposable installation instead. Browser Login Flow -and installed strict-Snap account setup were not repeated against a user's server. -The existing injected Login Flow tests passed; earlier browser/keyring acceptance -is recorded separately below. No real credentials were committed. - -The host rejects unprivileged network namespaces (`unshare --user --map-root-user ---net`: `Operation not permitted`), so this follow-up's spelling restart was not -network-isolated. Nextcloud offline/reconnect was explicitly simulated through -the live harness transport and verified against the real server. Installed strict -Snap runtime/interface tests still require host administration and remain -unverified here. Packaging and unpacked dependency checks are reported separately; -neither is claimed to prove installed confinement behavior. - - -### Follow-up changed files - -- `docs/development/nextcloud-notes-validation.md` -- `docs/development/nextcloud-notes.md` -- `lib/l10n/app_ar.arb` -- `lib/l10n/app_de.arb` -- `lib/l10n/app_en.arb` -- `lib/l10n/app_es.arb` -- `lib/l10n/app_et.arb` -- `lib/l10n/app_fa.arb` -- `lib/l10n/app_fr.arb` -- `lib/l10n/app_hi.arb` -- `lib/l10n/app_id.arb` -- `lib/l10n/app_it.arb` -- `lib/l10n/app_ja.arb` -- `lib/l10n/app_ko.arb` -- `lib/l10n/app_nb.arb` -- `lib/l10n/app_nl.arb` -- `lib/l10n/app_pl.arb` -- `lib/l10n/app_pt.arb` -- `lib/l10n/app_pt_BR.arb` -- `lib/l10n/app_ru.arb` -- `lib/l10n/app_tr.arb` -- `lib/l10n/app_uk.arb` -- `lib/l10n/app_vi.arb` -- `lib/l10n/app_zh.arb` -- `lib/l10n/app_zh_CN.arb` -- `lib/l10n/generated/app_localizations.dart` -- `lib/l10n/generated/app_localizations_ar.dart` -- `lib/l10n/generated/app_localizations_de.dart` -- `lib/l10n/generated/app_localizations_en.dart` -- `lib/l10n/generated/app_localizations_es.dart` -- `lib/l10n/generated/app_localizations_et.dart` -- `lib/l10n/generated/app_localizations_fa.dart` -- `lib/l10n/generated/app_localizations_fr.dart` -- `lib/l10n/generated/app_localizations_hi.dart` -- `lib/l10n/generated/app_localizations_id.dart` -- `lib/l10n/generated/app_localizations_it.dart` -- `lib/l10n/generated/app_localizations_ja.dart` -- `lib/l10n/generated/app_localizations_ko.dart` -- `lib/l10n/generated/app_localizations_nb.dart` -- `lib/l10n/generated/app_localizations_nl.dart` -- `lib/l10n/generated/app_localizations_pl.dart` -- `lib/l10n/generated/app_localizations_pt.dart` -- `lib/l10n/generated/app_localizations_ru.dart` -- `lib/l10n/generated/app_localizations_tr.dart` -- `lib/l10n/generated/app_localizations_uk.dart` -- `lib/l10n/generated/app_localizations_vi.dart` -- `lib/l10n/generated/app_localizations_zh.dart` -- `lib/src/assets/asset_ingestion_service.dart` -- `lib/src/assets/asset_limits.dart` -- `lib/src/nextcloud_notes/application/notes_repository.dart` -- `lib/src/nextcloud_notes/data/notes_api_client.dart` -- `lib/src/nextcloud_notes/data/notes_capabilities.dart` -- `lib/src/nextcloud_notes/data/notes_store.dart` -- `lib/src/nextcloud_notes/data/server_uri.dart` -- `lib/src/nextcloud_notes/domain/notes_conflict.dart` -- `lib/src/nextcloud_notes/domain/notes_models.dart` -- `lib/src/nextcloud_notes/presentation/notes_sidebar.dart` -- `lib/src/workspace/workspace_controller.dart` -- `test/src/nextcloud_capabilities_test.dart` -- `test/src/nextcloud_connection_test.dart` -- `test/src/nextcloud_login_flow_test.dart` -- `test/src/nextcloud_notes/notes_api_test.dart` -- `test/src/nextcloud_notes/notes_attachment_stream_test.dart` -- `test/src/nextcloud_notes/notes_conflict_test.dart` -- `test/src/nextcloud_notes/notes_repository_test.dart` -- `test/src/nextcloud_notes_ui_test.dart` -- `tools/nextcloud_notes_live_acceptance.dart` - -## Review corrections - -The review follow-up fixes all eight reported cases: unsaved-editor base -preservation across Refresh; exact Markdown export destination ranges; -uncertain-attachment controls with fetched remote state; replacement-tab -activation and clearing the final tab's derived content; consistent source and -WYSIWYG AI identities; deletion tombstones and media invalidation; concurrent -cache ownership (including previously duplicated rows); and disappearing -category filters. - -Thirteen regression tests were added. The race tests explicitly hold the -persistence debounce, which the ordinary test configuration previously bypassed. -They exercise edits before and during Refresh, verify base/local/remote -preservation in SQLite and assert that no PUT occurs. Additional cases exercise -own-write acknowledgments, both tab deletion paths, actual editor AI proposals -and stale-tab rejection, prefix-sharing export filenames, existing/fresh/restarted -media resolvers, retained history bytes, deliberate filename reuse after deletion, -uncertain-upload recovery controls and removal of the final category. - -The initial implementation's live-server and Snap results below predate these -review corrections. They are not claims that the updated Dart source was -retested against a live account or rebuilt as a Snap. No database schema, -dependency, native credential or packaging changes were needed for these fixes. - -Review follow-up validation: - -| Command/check | Result | -| --- | --- | -| `flutter pub get --enforce-lockfile` | Exit 0; no dependency changes | -| `flutter gen-l10n` | Exit 0; all 22 generated catalog files unchanged | -| `dart format --set-exit-if-changed .` | Exit 0; 538 files, zero changes | -| `flutter analyze --no-pub` | Exit 0; no issues | -| `flutter test --no-pub --concurrency=1 --reporter expanded test/src/nextcloud*_test.dart test/src/nextcloud_notes test/src/ai_edit_ui_test.dart test/src/wysiwyg_ai_test.dart test/src/source_audit_test.dart test/src/localization_audit_test.dart` | Exit 0; 216 passed, zero failed/skipped | -| Full suite: `flutter test --no-pub --concurrency=1 --reporter expanded` | Exit 0; 3,355 passed, zero failed/skipped, 12 minutes 59 seconds | -| `flutter build linux --release --no-pub` | Exit 0 | -| Updated release `--visualization-release-smoke` on isolated X11 | Exit 0; report `ok: true`, including HTML and PDF | -| `git diff --check` | Exit 0 | - -The full follow-up run used the resource environment shown below: - -```sh -BUSYMARK_NATIVE_DIALOG_TEST_DISPLAY=:97 \ -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" \ -flutter test --no-pub --concurrency=1 --reporter expanded -``` - -The application/test/native source hash snapshot remained unchanged throughout -the full run. The isolated Xvfb displays and release smoke private bus were -stopped afterward. - -Logs use the `/tmp/busymark-notes-review-` prefix. The X11 release report is -`/tmp/busymark-notes-review-visualization.json`. The first targeted test run had -four test-setup failures: two race tests inherited immediate test persistence, -and two AI tests had not selected a model. After correcting those setups, the -targeted suite above passed. - -## Environment and changes - -Validation used Linux x86_64, Flutter **3.47.5**, Dart **3.13.4**, GTK **3.24.41**, -libsecret **0.21.4** and WebKitGTK **2.52.6**. Existing SDK, build and package pins -were retained. Direct `sqlite3` **3.7.0** supplies the transactional store and its -bundled native library; the release library reports SQLite **3.53.4**. Required -transitive native-asset tooling changed to `hooks` **2.2.0** and `record_use` -**1.1.1**. No Snap interfaces or additional production native dependencies were -added. - -The focused database starts at schema **1** with transactional migration from an -empty store and rejection of unsupported newer schemas. Application sessions -advance to version **2**, retaining version-1 local-session decoding. No old -Notes API compatibility or generic provider framework was added. - -Changes cover the Notes domain/API/login/capability/credential/repository/store -module; explicit workspace/document origins and all save/session/recovery -paths; logical local-history identities; provider media and editor/export -integration; Notes settings/welcome/sidebar/tab/status/recovery UI; Git/path -and workspace-replacement guards; 23 localization catalogs and their generated -sources; native credential namespace policy; tests and acceptance tools. A -complete path manifest follows the validation results. - -## Live Nextcloud acceptance - -The disposable server was **Nextcloud 35.0.1** (internal version 35.0.1.1), with -the stable Notes **6.1.0** release. Notes tag `v6.1.0` resolves to -`0c3dd46dbd781b780c1ea60a13873198609bb98d`. The Docker image digest was -`sha256:b1ae671e9815401b0e837b19b9c778e89887721d67f0c8f9d34aa1d27a9a208f`. - -`tools/nextcloud_notes_live_acceptance.dart` was run against this real server. -The final report returned `ok: true` for all eleven checks: - -1. Notes version 6.1.0. -2. API capability 1.4. -3. Local durability before remote creation. -4. Content, category, favorite and canonical title updates. -5. Server title sanitization. -6. Attachment upload, fetch, deletion and authoritative renamed filename. -7. A second actor's ETag conflict and deliberate manual merge. -8. An offline new note with an attachment, SQLite close/reopen and reconnection. -9. Chunked listing, server Last-Modified and ETag/304 behavior. -10. Remote deletion preserving locally pending work. -11. Explicit fresh-state-checked deletion. - -The offline Notes scenario injected a transport failure into the genuine -repository, reopened its persistent SQLite store, then resumed real HTTP. It -was not a physical network cut or a full GUI/installed-Snap restart. - -Login Flow v2 used a real anonymous start and pending `404` poll. The system -default Firefox browser opened through `xdg-open` successfully. An automated -browser granted the real server authorization; polling then returned one-time -`200` credentials. The app password was revoked with HTTP `200` afterward. The -disposable account credentials, container and anonymous volume were removed. - -With Text **9.0.0** open in its real web editor, an otherwise valid conditional -Notes update returned **423 Locked**. The lock subsequently expired before a -repository-level unrelated-note isolation probe, so live per-note isolation -was not established. Deterministic tests cover preservation of the locked -note's pending work and continued synchronization of unrelated notes. - -No unsupported/private Text session protocol was used by the application. A -final check of current Text source and developer documentation found no -documented supported external native collaboration API. - -The live command was: - -```sh -dart run tools/nextcloud_notes_live_acceptance.dart -``` - -Sanitized reports are retained under `/tmp/busymark-notes-live/`: the final -eleven-scenario report, browser authorization report and lock report. The -private credential input was removed. - -## Initial repository validation commands - -These follow `.github/workflows/flutter-linux.yml`; display, dictionary and -bundled-tool environment variables provide the workflow's required resources. - -| Check | Result | -| --- | --- | -| `flutter pub get` and `flutter pub get --enforce-lockfile` | Exit 0; lockfile updated for the intentional SQLite dependency | -| `tools/validate_writerside_conformance.sh` | Exit 0; 181 checks from the official pinned builder | -| `flutter gen-l10n` | Exit 0; generated-file hashes unchanged by final regeneration | -| `dart format --set-exit-if-changed .` | Exit 0; 538 files, zero formatting changes | -| `flutter analyze --no-pub` | Exit 0; no issues found | -| Focused Notes tests, `flutter test --no-pub --concurrency=2 --reporter expanded test/src/nextcloud*_test.dart test/src/nextcloud_notes` | Exit 0; 122 passed, zero failed/skipped, 28 seconds | -| Full Flutter suite, `flutter test --no-pub --concurrency=1 --reporter expanded` | Exit 0; 3,342 passed, zero failed/skipped, 17 minutes 49 seconds | -| Final source and localization audits, `flutter test --no-pub --reporter expanded test/src/source_audit_test.dart test/src/localization_audit_test.dart` | Exit 0; 70 passed | -| Native spelling package: `dart pub get`, `dart test` | Exit 0; 13 tests passed | -| `tools/fetch_spelling_dictionaries.sh build/spelling-test` | Exit 0 | -| `flutter test test/src/spelling_bundle_integration_test.dart` with prepared resources | Exit 0; 3 tests passed | -| `flutter build linux --release --no-pub` | Exit 0; final Linux release and native SQLite asset built | -| `tools/test_gtk_accent.sh` and `tools/test_gtk_accent_host.sh` on X11 | Exit 0 | -| `tools/test_secure_credential_policy.sh` | Exit 0 | -| `tools/test_nextcloud_libsecret.sh` with an isolated keyring | Exit 0; actual create/read/delete | -| Native Writerside dialog tests with isolated Xvfb | Exit 0; 2 tests passed, zero skipped | -| `tools/visualization_smoke.py` on X11 and Wayland | Exit 0 for both | -| Final release `--visualization-release-smoke` on X11 and Wayland | Exit 0; both reports `ok: true`, HTML and PDF exercised | -| Release `--spelling-release-smoke`, install/restart and network-disabled restart | Exit 0; reports `ok: true` | -| Ordinary release Welcome → Nextcloud Notes setup with an empty isolated profile | Passed; actual AOT SQLite loaded, schema 1, integrity `ok`, zero accounts, private DB permissions | -| Clean Snapcraft build and unconfined packaged-resource validation | Exit 0; exact source/recipe provenance and all resource checks passed | -| `git diff --check` | Exit 0 | - -The full suite used these resources: - -```sh -BUSYMARK_NATIVE_DIALOG_TEST_DISPLAY=:97 \ -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" \ -flutter test --no-pub --concurrency=1 --reporter expanded -``` - -An isolated Xvfb server provided display `:97`. The final full-run source hash -snapshot remained unchanged throughout the run. Logs are retained at -`/tmp/busymark-nextcloud-full-tests-serial.log`, -`/tmp/busymark-nextcloud-focused-final.log` and -`/tmp/busymark-nextcloud-audits-final.log`. Final localization regeneration -preserved generated-file hashes; the workflow's clean-checkout -`git diff --exit-code` cannot be used literally while implementation changes -are still uncommitted. - -The local environment lacked `xvfb-run`, Weston and `python3-gi-cairo`. The -corresponding distro binaries/libraries were extracted under `/tmp` and used -for the checks without installing host packages. CI's privileged network -namespace step could not run directly; a Docker container with no network -provided the actual offline spelling check. - -Before the final source snapshot, two full runs exposed existing local UI -wall-clock waits: the first had 3,304 passed, one skipped and three failures -(two obsolete source-audit assertions and a spelling-fixture timeout); the -audits were updated for the intended integration. The next had 3,331 passed, -one skipped and two local UI timing failures. Both local UI failures passed in -an isolated rerun, and the spelling-fixture timeout also passed in isolation. -A later run started before the last fixes and was stopped because it retained -older compiled shared source. These runs are not represented as clean passes. -The final fixed-source serial run passed all 3,342 tests without weakening the -existing local-workflow assertions or timeouts. - -## Initial Linux, browser and keyring checks - -The native credential policy probe passed, retaining the AI key whitelist and -restricting Nextcloud keys to the dedicated UUID namespace. An isolated -libsecret keyring exercised actual credential creation, read and deletion. -Authentication mocks additionally cover missing/locked keyring behavior and -absence of plaintext fallback. - -X11 and headless Wayland release visualization/HTML/PDF smoke reports returned -`ok: true`. Real WebKit visualization checks passed under both display systems. -The GTK accent lookup and channel-host/lifecycle probes passed. Existing engine -teardown diagnostics appeared on stderr without failing the lifecycle probe. - -Spelling installation and profile restart passed. A separate Docker container -with `--network none` verified an actual offline Wayland restart retained the -installed dictionary and personal word. The container required the test-only -WebKit sandbox-disable environment flag because its user namespaces were -unavailable; production sandbox configuration was unchanged. - -The ordinary final release executable was also launched without smoke-test -arguments, using an empty isolated XDG profile and Xvfb. Welcome → Nextcloud -Notes rendered the connection setup and initialized SQLite through the actual -Dart AOT/native asset path. The server field remained empty and no account was -connected. A read-only inspection found schema version **1**, zero accounts, -`integrity_check: ok`, all four expected tables, directory mode **0700**, and -database/WAL/SHM modes **0600**. No Dart exception or native-loader/store error -was logged. A first private-bus attempt encountered a host desktop-portal/FUSE -delay; a minimal isolated bus without host service autostart passed. This checks -the release initialization and entry point, not interactive account acceptance. -The sanitized report, log and inspected screenshots are retained under -`/tmp/busymark-final-aot-notes-ufxzfivk/`. The app, private bus, Xvfb, test server, -Snap builder and Weston processes created for this work were stopped. - -## Initial Snap packaging - -The final artifact was built from a disposable source snapshot with the official -`ghcr.io/canonical/snapcraft:8_core24` image, Snapcraft **8.11.1**, and the -repository's Flutter **3.47.5** / Dart **3.13.4** pins. At that build, -application/native sources, localization catalogs, lockfile and the embedded -recipe matched the repository. -The container required restoration of its missing extension source and console -entry point from the exact same official Snapcraft version; this affected only -the disposable build environment. - -```sh -SNAPCRAFT_BUILD_INFO=1 snapcraft clean --destructive-mode --verbosity=brief -SNAPCRAFT_BUILD_INFO=1 snapcraft pack --destructive-mode --verbosity=brief -``` - -The artifact is -`/tmp/busymark-nextcloud-snap-source/final-source/busymark_0.5.1_amd64.snap`, -**309,583,872 bytes**, SHA256 -`339a2eb9c1c9911f071f5c3a37d6aa535d0daed205c5ec2a14bd9f25a7203a55`. -It contains the build manifest and exact recipe, SHA256 -`af358e17aadbd4f816069eccd6d4656818d489443b17345cbe0ad78e481dc590`. - -Package validation confirmed strict/core24/amd64 metadata, unchanged network, -browser and keyring plugs, GNOME/Mesa library selection, no missing shared -dependencies, loading SQLite **3.53.4**, application plugins, Git/SSH/setsid, -WebKit helpers, visualization engines, SVG loaders, codecs, fonts/licenses, -Yaru resources and desktop integration. The package-resource probe ran -**unconfined** and returned exit 0; this does not establish installed strict -runtime behavior. - -An initial probe exposed Snapcraft's forced RPATH overriding the GNOME runtime -library selection. The recipe now uses its existing `patchelf` dependency to -preserve bundled paths as RUNPATH, allowing the extension runtime to select its -libraries. Intermediate incremental builds also retained stale priming state; -the final artifact came from a complete lifecycle clean and passed provenance -and resource checks. No interfaces or dependencies were added for this fix. -The sanitized result is `/tmp/busymark-nextcloud-snap-validation.json`; the -complete build log is -`/tmp/busymark-nextcloud-snap-source/.task-snap-final-all-clean.log`. - -## Limits on acceptance - -Strict installation/refresh and confined browser, keyring and network behavior -of the newly built Snap require host administrative permissions unavailable in -this session. The installed older BusyMark Snap was left untouched. These -installed-Snap checks are not claimed as passed. - -The real account authorization and repository protocol checks do not constitute -a complete interactive GUI Notes acceptance run. Widget/controller tests cover -source/WYSIWYG boundaries, save/autosave/shutdown, session/history, media/export, -remote UI and local workflow regressions. The uncertain-create/twin-adoption -race is covered deterministically, not reproduced on the live server. - -## Changed-file manifest - -129 source, test, localization, build and documentation files changed from the initially clean checkout. - -```text -.github/workflows/flutter-linux.yml -docs/development/nextcloud-notes-validation.md -docs/development/nextcloud-notes.md -lib/l10n/app_ar.arb -lib/l10n/app_de.arb -lib/l10n/app_en.arb -lib/l10n/app_es.arb -lib/l10n/app_et.arb -lib/l10n/app_fa.arb -lib/l10n/app_fr.arb -lib/l10n/app_hi.arb -lib/l10n/app_id.arb -lib/l10n/app_it.arb -lib/l10n/app_ja.arb -lib/l10n/app_ko.arb -lib/l10n/app_nb.arb -lib/l10n/app_nl.arb -lib/l10n/app_pl.arb -lib/l10n/app_pt.arb -lib/l10n/app_pt_BR.arb -lib/l10n/app_ru.arb -lib/l10n/app_tr.arb -lib/l10n/app_uk.arb -lib/l10n/app_vi.arb -lib/l10n/app_zh.arb -lib/l10n/app_zh_CN.arb -lib/l10n/generated/app_localizations.dart -lib/l10n/generated/app_localizations_ar.dart -lib/l10n/generated/app_localizations_de.dart -lib/l10n/generated/app_localizations_en.dart -lib/l10n/generated/app_localizations_es.dart -lib/l10n/generated/app_localizations_et.dart -lib/l10n/generated/app_localizations_fa.dart -lib/l10n/generated/app_localizations_fr.dart -lib/l10n/generated/app_localizations_hi.dart -lib/l10n/generated/app_localizations_id.dart -lib/l10n/generated/app_localizations_it.dart -lib/l10n/generated/app_localizations_ja.dart -lib/l10n/generated/app_localizations_ko.dart -lib/l10n/generated/app_localizations_nb.dart -lib/l10n/generated/app_localizations_nl.dart -lib/l10n/generated/app_localizations_pl.dart -lib/l10n/generated/app_localizations_pt.dart -lib/l10n/generated/app_localizations_ru.dart -lib/l10n/generated/app_localizations_tr.dart -lib/l10n/generated/app_localizations_uk.dart -lib/l10n/generated/app_localizations_vi.dart -lib/l10n/generated/app_localizations_zh.dart -lib/src/ai/ai_edit_ui.dart -lib/src/app/busymark_app.dart -lib/src/assets/document_media_context.dart -lib/src/assets/provider_asset_ingestion_service.dart -lib/src/editor/markdown_image_view.dart -lib/src/editor/source/source_editor.dart -lib/src/editor/writerside_video_view.dart -lib/src/editor/wysiwyg/wysiwyg_block_widgets.dart -lib/src/editor/wysiwyg/wysiwyg_editor.dart -lib/src/export/html_export_assets.dart -lib/src/export/html_export_links.dart -lib/src/export/html_export_models.dart -lib/src/export/html_export_service.dart -lib/src/export/html_export_ui.dart -lib/src/export/html_rich_content.dart -lib/src/export/markdown_copy_export_service.dart -lib/src/export/markdown_export_assets.dart -lib/src/export/markdown_pdf_export_service.dart -lib/src/export/markdown_pdf_export_ui.dart -lib/src/export/markdown_pdf_models.dart -lib/src/export/markdown_visualization_export.dart -lib/src/git/application/git_controller.dart -lib/src/local_history/local_history_comparison_view.dart -lib/src/local_history/local_history_controller.dart -lib/src/local_history/local_history_models.dart -lib/src/local_history/local_history_store.dart -lib/src/nextcloud_notes/application/attachment_markdown.dart -lib/src/nextcloud_notes/application/nextcloud_connection.dart -lib/src/nextcloud_notes/application/notes_media.dart -lib/src/nextcloud_notes/application/notes_repository.dart -lib/src/nextcloud_notes/data/login_flow.dart -lib/src/nextcloud_notes/data/notes_api_client.dart -lib/src/nextcloud_notes/data/notes_attachment_references.dart -lib/src/nextcloud_notes/data/notes_capabilities.dart -lib/src/nextcloud_notes/data/notes_secret_store.dart -lib/src/nextcloud_notes/data/notes_store.dart -lib/src/nextcloud_notes/data/server_uri.dart -lib/src/nextcloud_notes/domain/notes_models.dart -lib/src/nextcloud_notes/presentation/nextcloud_settings.dart -lib/src/nextcloud_notes/presentation/notes_sidebar.dart -lib/src/search/search_replace_service.dart -lib/src/visualization/openapi_dependency_resolver.dart -lib/src/visualization/visualization_card.dart -lib/src/visualization/visualization_models.dart -lib/src/workspace/document_buffer.dart -lib/src/workspace/document_origin.dart -lib/src/workspace/presentation/settings_screen.dart -lib/src/workspace/presentation/welcome_screen.dart -lib/src/workspace/presentation/workspace_screen.dart -lib/src/workspace/session_persistence.dart -lib/src/workspace/workspace_controller.dart -lib/src/workspace/workspace_glyphs.dart -lib/src/workspace/workspace_model.dart -lib/src/workspace/workspace_service.dart -linux/runner/credential_key_policy.h -linux/runner/secure_credential_host.cc -pubspec.lock -pubspec.yaml -snap/snapcraft.yaml -test/src/ai_edit_ui_test.dart -test/src/localization_audit_test.dart -test/src/nextcloud_capabilities_test.dart -test/src/nextcloud_connection_test.dart -test/src/nextcloud_document_model_test.dart -test/src/nextcloud_editor_boundary_test.dart -test/src/nextcloud_login_flow_test.dart -test/src/nextcloud_media_export_test.dart -test/src/nextcloud_notes/notes_api_test.dart -test/src/nextcloud_notes/notes_attachment_references_test.dart -test/src/nextcloud_notes/notes_repository_test.dart -test/src/nextcloud_notes_ui_test.dart -test/src/nextcloud_secret_store_test.dart -test/src/nextcloud_workspace_test.dart -test/src/openapi_dependency_resolver_test.dart -test/src/source_audit_test.dart -tools/nextcloud_libsecret_probe.cc -tools/nextcloud_login_live_acceptance.py -tools/nextcloud_notes_live_acceptance.dart -tools/secure_credential_policy_probe.cc -tools/test_nextcloud_libsecret.sh -tools/test_secure_credential_policy.sh -``` diff --git a/docs/development/nextcloud-notes.md b/docs/development/nextcloud-notes.md index 11446dde..63a8f337 100644 --- a/docs/development/nextcloud-notes.md +++ b/docs/development/nextcloud-notes.md @@ -5,17 +5,14 @@ account. The SQLite database and managed attachment files are implementation details for offline editing, recovery and synchronization; they are not another workspace. Existing local Markdown and Writerside storage paths remain separate. -## Verified upstream contract +## Supported API contract -The implementation was researched against **Notes 6.1.0**, released September 15, -2026, stable tag `v6.1.0`, commit -`0c3dd46dbd781b780c1ea60a13873198609bb98d`. On October 5, 2026 the App Store -listed this release for Nextcloud **33, 34 and 35**; no newer stable Notes release -was listed. The package permits `>=33,<37`, which does not make an unreleased -server a tested baseline. Live acceptance used **Nextcloud 35.0.1** with this -exact Notes release. +The implementation targets **Notes API 1.4** in major version 1, including +Notes **6.0.x** and **6.1.x**. Attachment deletion additionally requires Notes +**6.1.0** or later. Server compatibility depends on the installed Notes app and +its advertised capabilities. -Sources reviewed before implementation: +Upstream contract references: - [App Store](https://apps.nextcloud.com/apps/notes) and [stable release](https://github.com/nextcloud/notes/releases/tag/v6.1.0). @@ -27,7 +24,7 @@ Sources reviewed before implementation: `playwright/e2e/attachment-api.spec.ts`. - [Login Flow v2 and password revocation](https://docs.nextcloud.com/server/stable/developer_manual/client_apis/LoginFlow/index.html), [OCS authentication/capabilities](https://docs.nextcloud.com/server/stable/developer_manual/client_apis/OCS/ocs-api-overview.html). -- Current Notes issues [1940](https://github.com/nextcloud/notes/issues/1940), +- Notes issues [1940](https://github.com/nextcloud/notes/issues/1940), [2037](https://github.com/nextcloud/notes/issues/2037), [1955](https://github.com/nextcloud/notes/issues/1955), [1999](https://github.com/nextcloud/notes/issues/1999), @@ -41,9 +38,8 @@ There is no Notes application-version minimum for connection. Missing or malform capability fields are ignored. No API <1.4, deprecated 0.2, or other protocol is implemented. -The October 5 compatibility review repeated `docs/api/README.md`, `docs/api/v1.md`, -`lib/AppInfo/Application.php`, `lib/AppInfo/Capabilities.php`, and `appinfo/routes.php` -against these exact tags: +The tagged `lib/AppInfo/Capabilities.php` and `appinfo/routes.php` establish +the supported baseline alongside `docs/api/README.md` and `docs/api/v1.md`: | Tag | Commit | API capability | Attachment endpoints | | --- | --- | --- | --- | @@ -114,10 +110,8 @@ newer unsupported schemas. 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. -No database encryption or general account-provider framework was introduced. -The lockfile adds only `sqlite3` and its required native-asset tooling upgrades -(`hooks` 2.2.0 and `record_use` 1.1.1). Flutter 3.47.5 and Dart 3.13.4 were used; -the repository's SDK and toolchain pins were preserved. +The database is not encrypted. This is a dedicated Notes integration, with no +general account-provider framework. An editor revision is saved only after its local transaction succeeds. HTTP acknowledgment is a separate operation. Save, Save All, autosave, recovery, @@ -188,22 +182,41 @@ from the recorded wire content. There is no hidden Markdown marker. There is no documented create idempotency key. A transport failure, malformed response or 5xx after creation enters `creationUncertain`; restart after an in-flight create does the same. Reconciliation requests an unpruned collection. -A plausible candidate must have an unseen ID and equal wire content, favorite -and modified. Automatic adoption additionally requires exact wire title and -category, exactly one match, exactly one matching local attempt, nonempty -content, and no existing downloaded local instance. A title or category changed -by server sanitization/deduplication remains an unresolved candidate for the user -to adopt deliberately. Candidate IDs survive restart, and the comparison UI can -select among multiple plausible notes. Empty-content matches, missing evidence, -or an existing instance also require deliberate resolution. Timestamps alone -are never identity. These are conservative correlation checks, not a server -idempotency guarantee; an independently created fully identical note cannot be -proven distinct. BusyMark never automatically retries an uncertain POST. - -Adoption preserves the logical identity and any newer local revision, adopts the -server ID/base/ETag, and retains pending attachment bytes/references. Attachment -publication then resumes through the existing durable upload/update sequence. -Recovery as a new note is an explicit user decision. +A plausible candidate has an unseen ID and equal wire content, favorite and +modified. Exact and sanitized payload matches only rank possible candidates; +they never establish identity. Every uncertain outcome remains blocked across +refresh, reconnect, autosave and restart, including a single identical nonempty +candidate and an empty result. Discovery persists candidates without binding, +acknowledgment, clearing the attempt or permitting PUT, DELETE or publication. +An independently created identical note cannot be distinguished by its payload. + +The comparison presents the selected server ID, title, category, modification +time and content. **Use this server note** explicitly links the draft and then +synchronizes retained local edits. Confirmation supplies an immutable review +containing the draft revision, account, attempt identity and selected state. +The repository fetches that ID through the authenticated account and compares +all state fields, including permissions. Changed, unavailable, missing or +read-only candidates stay unresolved and need another review. Account removal, +new local revisions and repeated confirmation invalidate the decision. + +Binding preserves the draft's logical identity and history. The fresh server +ID/base/ETag and outbox commit with removal of a clean downloaded duplicate in +one SQLite transaction. Durable duplicate edits and pending attachment +operations block consolidation. Every workspace controller also registers an +open-tab guard; candidate tabs must be closed after preserving their work. +Reserved candidates cannot be opened during confirmation. The same commit-time +editor guard rejects unsaved draft edits that arrived during the confirmation +GET, requiring another review. Newer draft edits and staged bytes remain +pending through the existing ordered attachment/update pipeline, and subsequent PUTs retain If-Match protection. + +**Create a separate note** warns that the first request may already have created +a server note. The explicit decision durably records one replacement identity +and copies recoverable work and attachment bytes atomically. Concurrent clicks +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. Read-only notes block content/title/category editing while favorite remains writable under the documented contract. A note becoming read-only does not @@ -297,12 +310,41 @@ attachment stages/media security and BusyMark save/session/history/export/UI integration. Native probes cover the credential whitelist and actual libsecret create/read/delete in an isolated keyring. -`tools/nextcloud_notes_live_acceptance.dart` accepts a private credential JSON -path and exercises an explicit disposable fixture. The browser helper uses -Login Flow v2; do not commit credentials or use a real production account for -destructive acceptance tests. Validation results and environment limitations -are recorded in [the validation record](nextcloud-notes-validation.md); mocked coverage is not a -substitute for claiming real server acceptance. +Run the existing automated coverage from the repository root: + +```bash +flutter test --no-pub test/src/nextcloud_notes test/src/nextcloud*_test.dart +bash tools/test_secure_credential_policy.sh +bash tools/test_nextcloud_libsecret.sh +``` + +The libsecret probe requires development headers, `pkg-config`, a C++ compiler, +D-Bus, and `gnome-keyring-daemon`; it creates an isolated temporary keyring. +Run `flutter test --no-pub` for the shared editor, session, history, export and +packaging regressions as well. + +For live acceptance, use an isolated HTTPS test account and keep credentials +and reports outside the repository. The browser harness requires Python +`requests`, Playwright and Chrome. Its account JSON contains `server`, `user` +and `password`; it writes private app credentials containing `server`, +`loginName` and `appPassword` for the Dart harness: + +```bash +acceptance_dir="$(mktemp -d)" +python3 tools/nextcloud_login_live_acceptance.py \ + /path/to/private-test-account.json "$acceptance_dir/app-credentials.json" \ + "$acceptance_dir/login.json" +dart run tools/nextcloud_notes_live_acceptance.dart \ + "$acceptance_dir/app-credentials.json" "$acceptance_dir/notes.json" +``` + +The Dart harness also accepts a third argument naming a test CA certificate. +It exercises disposable notes and attachments, including conflicts, offline +saves, restart, and explicit adoption after uncertain creation. Repeat against +the supported Notes 6.0.x and 6.1.x baselines when changing the API contract; +mocked tests alone do not establish live server compatibility. Keep the +checkpoint, revision, explicit-adoption and attachment ownership invariants +above covered, and remove temporary acceptance data when finished. ## Explicit non-goals diff --git a/docs/development/writerside-toc-implementation.md b/docs/development/writerside-toc-implementation.md deleted file mode 100644 index a18d5efb..00000000 --- a/docs/development/writerside-toc-implementation.md +++ /dev/null @@ -1,429 +0,0 @@ -# Writerside Table of Contents implementation record - -Status: authoring workflows implemented, including native GTK submenus and the -approved template workflow; exact-original UI coverage remains incomplete. -The two unidentified icon-only toolbar controls are still unimplemented. -This is **not a claim of complete IDE, website, or pixel-perfect Writerside -parity**; retained safety and integration differences are listed below. - -## Baseline and decisions - -The receiving checkout was clean at `c3b53ff` (BusyMark 0.4.2). The named handoff -implementation points matched the receiving source; no archive was copied over it. -Flutter 3.47.5 and Dart 3.13.2 were used without upgrading dependencies. - -User decisions: retain BusyMark AI, Git, clipboard, and file commands **after** the -Writerside actions; use installed Writerside **2026.07.8925** for undocumented -behavior; reuse the existing usage-review sidebar, titled **Find**. - -The user approved application-support user-template storage and a dedicated -**File and Code Templates** editor, without project metadata. The installed -**Create Topic from Template** dialog takes precedence over the obsolete submenu -screenshot. The user also explicitly required BusyMark's existing native Linux -menus, not Material dropdowns. - -## Evidence - -The handoff's source identifiers are retained: - -| Ref | Official source | -| --- | --- | -| S1 | [Table of contents](https://www.jetbrains.com/help/writerside/table-of-contents.html) | -| S2 | [Context menu](https://resources.jetbrains.com/help/img/writerside/go-to-toc-element.png) | -| S3 | [Row presentation](https://resources.jetbrains.com/help/img/writerside/toc-elements-toc-view.png) | -| S4 | [Topics and titles](https://www.jetbrains.com/help/writerside/topics.html) | -| S5–S7 | [Basic title dialog](https://resources.jetbrains.com/help/img/writerside/edit-title-action.png), [instance title](https://resources.jetbrains.com/help/img/writerside/edit_title_instance_specific.png), [TOC title](https://resources.jetbrains.com/help/img/writerside/edit_title_toc_title.png) | -| S8 | [Add topics](https://www.jetbrains.com/help/writerside/add-a-topic.html) | -| S8a | [Import from Markdown](https://www.jetbrains.com/help/writerside/import-markdown.html) | -| S9–S10 | [Reuse topics](https://www.jetbrains.com/help/writerside/reuse-topics.html), [creation menu](https://resources.jetbrains.com/help/img/writerside/reuse-from-another.png) | -| S11–S12 | [Remove topics](https://www.jetbrains.com/help/writerside/delete-a-topic.html), [removal dialog](https://resources.jetbrains.com/help/img/writerside/remove_topic_dialog.png) | -| S13 | [Save topic as template](https://www.jetbrains.com/help/writerside/save-as-template.html) | -| S14 | [Preview topics](https://www.jetbrains.com/help/writerside/preview-topics.html) | -| S15 | [Instances and tree format](https://www.jetbrains.com/help/writerside/instances.html) | - -Installed-build evidence (**P**) is bytecode/resource inspection, not an interactive -JetBrains UI observation. Source artifact: -`/home/albert/.local/share/JetBrains/PyCharm2026.2/writerside/lib/writerside-2026.07.8925.jar`. -SHA-256: `0493a38ac6f09840302b150641fab8b91697955896572c7bf4367bbf7a08f810`. -Reproduce with `unzip -p` for resources and `javap -c -p -classpath ` -using the installed JDK. Relevant classes/resources: - -- `stardust.ui.tocTree.TocTreePopupKt`: context order, Group and Sort positions. -- `stardust.ui.views.dom.tree.actions.NewArticleActionGroup`: creation order. -- `stardust.ui.views.dom.tree.actions.DuplicateTopicAction`: source duplication, - new XML root ID, basic sibling reference rather than copied TOC descendants. -- `stardust.actions.CopySpecialAction` and `stardust.ui.tocTree.CopyTopicFileNameAction`, - `CopyTopicFilePathAction`, `CopyTopicTitleAction`, `CopyTocOwnIdAction`: labels, - order, availability, raw reference/canonical path/base title/explicit ID payloads. -- `stardust.ui.changes.EditTitleDialog`: collapsed advanced fields, blank override - removal and required base title. -- `stardust.ui.views.dom.tree.actions.GroupAction`: two or more siblings, source - ordering, New Group/Group Name/Enter interaction. -- `com.intellij.writerside.intentions.SortChildrenAlphabeticallyAction`: shallow, - case-sensitive natural string comparison of resolved tree-node names. -- `stardust.ui.views.dom.tree.actions.LinkTopicFileToTocAction`: single selection, - name filtering, picker caption and current-instance exclusion. -- `stardust.ui.changes.RemoveElementDialog`, `TocRemoveDialogData`, and - `RemoveTocElementIntention`: compact versus full removal and automatic-update default. -- `stardust.ui.views.dom.tree.actions.ScrollProductTreeToFileAction`, - `stardust.ui.tocTree.TocTreeImpl`, `TocElementKey`, `TocTreeAsyncView`: - explicit synchronization and breadth-first first topic match. -- `stardust.ui.views.dom.tree.TopicFromTemplate`, `actions.newtopic.NewTopicDialog`, - `actions.TemplateFromTopicAction`, `fileTemplates/internal/`, and - `tgdpTemplates/`: real template resources and modern creation/settings entry points. - `LocalFileTemplateProvider` and `FileTemplateUtilKt` establish simultaneous - literal `${TITLE}`/`${ID}` substitution, not Velocity execution. - `NewTopicDialog.Companion` establishes XML-only title escaping and filename - conversion; `TGDPFilesImporter.Companion` establishes brace adaptation, - first top-level H1 replacement, and relative-link resolution. -- `i18n/StardustBundle.properties`: installed English strings/defaults. - -## Action acceptance matrix - -Implementation paths below abbreviate `workspace_screen.dart` as **WS**, -`writerside_toc_dialogs.dart` as **Dialogs**, and the controller/service by class. -Test abbreviations: **A** = `writerside_toc_actions_test.dart`; **W** = -`writerside_toc_workspace_test.dart`; **M** = `writerside_toc_menu_test.dart`; -**E** = `writerside_toc_editor_test.dart`; **C** = `writerside_topic_creator_test.dart`; -**R** = `writerside_topic_removal_service_test.dart`; **App** = `app_smoke_test.dart`; -**TS/TD** = `writerside_template_service_test.dart` / `writerside_template_dialogs_test.dart`. - -“Native” means the production Flutter Linux application driven by the disposable -fixture harness, with source-file assertions and actual rendered captures. GTK -menus are activated through Linux accessibility and X11 keyboard events, not a -Flutter menu substitute. It is not a human manual run or an original Writerside -screenshot comparison. - -| Evidence | Final location / exact English label | Implementation | Behavioral verification | Linux/manual result / differences | -| --- | --- | --- | --- | --- | -| S1, S3, S4 | Sidebar: **Table of Contents** | `WritersideTocPresenter`, `WS._TocTabState` | W: contextual titles, instances, markers, included ownership; App: Files/TOC and RTL regressions | Native pane captured and inspected. Ordinary rows have no document icon; hidden rows remain muted. Existing glyphs are not JetBrains artwork. | -| S1, S2, P | Context: **New Topic** → choices; toolbar + uses current selection | `WS._showTocTreeMenu`, `_createTocItem`, `_TocHeader` | App: selected sibling path/identity; C: creation guards | Native sibling Markdown and template creation passed. | -| S1, S2, P | Context: **New Child Topic** → choices | Same dispatch with child placement | App: XML format and child source identity; C | Native XML child creation passed. | -| S8, P | Either creation menu: **Empty MD Topic**, **Empty XML Topic**; dialog **New Topic** | `WS._CreateWritersideTopicDialog`, `WritersideTopicCreator.create` | C: formats, roots, collisions, races, first home page; controller monitor-race and dirty inactive-tree tests | Native both formats passed; new documents opened with other tabs retained. Existing BusyMark empty-body content retained; not a claim of identical installed template bytes. | -| S1, P | Creation menu: **Empty Group**; dialog **New Empty Group**, **TOC title:**, **Cancel**, **OK** | `Dialogs.WritersideTocTextDialog`, `WritersideTocEditor.insertElement` | E/A structural paths; guarded insertion service | Native creation passed: a titled element without a topic reference. | -| S9–S10, P | Creation menu after separator: **Link Topic Files to TOC...**; picker **Select Topic to Add to the Current Instance** | `Dialogs.WritersideExistingTopicPicker`, `WorkspaceService.insertWritersideTocElement` | M: filter/arrows/submit/cancel; W: unchanged bytes and stale eligibility | Native linking passed, including unchanged source bytes. Candidates use canonical resolved membership, including includes. | -| S8, S8a | **New Topic** and header Add: **Add Local Markdown Files**; dialog **Add Local Markdown Files** | Existing Markdown discovery and `WritersideInstanceService.addMarkdownTopics`; focused selection dialog | Instance service: selected-only/root/sibling ordering, relative layout, media, first home page, ID and concurrency failures; workspace/controller/dialog/menu suites | Context imports are siblings and header imports are roots. **New Child Topic** intentionally has no import action. Imported Markdown bytes are unchanged. | -| S2, P | Context after creation separator: **Duplicate**; **Duplicate Topic**, **Topic Filename:** | `WorkspaceService.duplicateWritersideTopic`, guarded creator | W: new XML ID, unchanged body, basic sibling only; C publication guards | Native duplication passed: source copy opened with its new XML root ID. Filename validation retains BusyMark's conservative identifier restrictions; race errors use existing workspace error presentation. | -| S2, P | Context: **Copy Special** → **Topic File Name '{name}'**, **Topic File Path**, **Topic Title '{title}'**, **TOC Element ID '{id}'** | `WS._showTocTreeMenu`, `_showTopicContextMenu` | App: all four Copy Special clipboard payloads in RTL, including base versus navigation title; M nested keyboard/focus/RTL | Native menu captured. Payloads are raw topic reference, canonical path, base title, explicit ID; unavailable values are disabled. No claim that contextual TOC title is copied. | -| S2, S14, user decision | JetBrains **Preview Topic** is deliberately not exposed | No BusyMark TOC action | Framework and native menu-absence regressions | The removed action only opened the selected topic in BusyMark's ordinary Reading mode, duplicating existing document view controls and incorrectly implying Writerside Preview-tool-window semantics. BusyMark retains its separate Reading and Split document modes. A true Writerside Preview feature is outside this change; no Preview-tool-window or browser-preview parity is claimed. | -| S2, S4–S7, P | Context: **Edit Title...**; dialog **Edit Title** with **Topic title:**, **Advanced Settings**, **Title for '{id}':**, **TOC-only title:**, **Cancel**, **OK** | `Dialogs.WritersideTitleDialog`, `WritersideTitleEditor.prepare`, `WorkspaceService.editWritersideTitles` | A: XML/Markdown escaping, independent overrides, inherited values, clearing, repeated occurrence; W: rollback on second-file guard | Native dialog/advanced fields captured and inspected. Front-matter title editing is rejected rather than silently changing a shadowed H1; edit that BusyMark extension in source. | -| S11–S12, P | Context: **Remove TOC Element**; dialog **Remove TOC Element** | `WS._runWritersideTopicRemoval`, `WritersideTopicRemovalService` | R: project-wide references, `topic`/`ref`/`origin`, redirects, malformed or incomplete semantic discovery, races, file retained/deleted; W: project-wide dirty guard and rollback | The approved sidebar Review Usages flow is instance-aware; Do Refactor reanalyzes and completes directly when all blocking usages are resolved. | -| S1, P | Multi-selection: **Remove TOC Elements...**; compact **Remove {count} TOC Elements** | `WS._removeTocEntries`, guarded batch structural removal | E: source identities and direct-child promotion; App: multi-selection route | Existing direct-child promotion is preserved, as required by handoff. Installed descendant-inclusive count/removal is not cloned; selected-entry count is shown. | -| S11, user decision | Existing sidebar: **Find**, **Do Refactor** | `_WritersideTopicUsageReviewPanel`, usage navigation/reanalysis orchestration | R and controller stale-analysis tests; native review transition | Native surface captured and inspected. Sidebar placement is explicitly user-approved; this is not a general JetBrains tool-window clone. | -| S11 | Files context: **Refactor** → **Safe Delete**; dialog **Delete**, mandatory checked **Safe Delete**, **OK** | Files nested menu, same topic-removal analyzer/apply service | R: project-wide generic-delete bypass prohibited (including unparsed/non-active-module topics), usage checks, orphan choices; W: rollback | Safe Delete is deliberately non-toggleable for Writerside topic files. | -| S2, S15, P | Context: **Set as Home Page** | `WritersideTocEditor.setHomePage`, controller/service | W: root attributes and other instance unchanged; C: first-topic initialization retained | Native reassignment passed and marker inspected. No home action for groups, URLs or library instances; included structural mutation remains restricted. | -| S1, P | Multi-selection after Duplicate: **Group**; **New Group**, **Group Name**, Enter | `WritersideTocEditor.groupElements`, `Dialogs.WritersideTocTextDialog` | A: source order, full nodes, cross-parent rejection; E guards | Native Group/Enter passed, wrapping the two selected source subtrees. Same-parent selections only, preserving complete XML subtrees. | -| S1, P | Context after source navigation: **Sort Child Topics Alphabetically** | `WorkspaceService.sortWritersideTocChildren`, `WritersideTocEditor.reorderChildren` | W: concurrent resolved-title dependency change rejected; native expected order | Native shallow contextual-title order passed. Deliberately rejects mixed XML child entries instead of reproducing the installed action's loss of includes/non-TOC tags. | -| S1 | Pointer: before/after/child drop, single/multiple selection | `WS._TocTabState` drag handlers, `WritersideTocEditor.moveSubtrees` | App: actual recognizers, child/before zones, multi-payload, ancestor rejection; A/E: full subtrees and identities | Native multi-item child drop passed; moved entries remained selected and descendants visible. Widget checks pass for collapsed-hover expansion and autoscroll to a distant destination in a 42-entry tree. | -| S1, S2 | Context: **Go to TOC Element in '{tree filename}'** | `WS._goToTocElement`, `writersideTocSourceSpan`, `SourceEditorState.scrollToOffset` | A: repeated references on one line and fresh offsets; source widget: folded destination; W: snippet owner | Native active and included owner source navigation passed; captures inspected. Native preview-only → source navigation also passed. | -| S1 | Topic/group double-click | TOC `WS._SidebarTreeRow` and `_SidebarRowSurface` callbacks | App: empty-group double-click/source-open assertion; exact-source helper tests | Production callbacks implemented; dedicated native double-click check not yet recorded. Existing single-click semantics retained. | -| S1, P | Toolbar: **Synchronize TOC and Editor** | `WS._rememberSyncFocus`, `_synchronizeToc`, `writersideTocBreadthFirstPath` | A: installed breadth-first duplicate match and tree-editor key behavior | Native both synchronization directions passed, including pre-toolbar focus tracking. Installed `.tree` editor action unusually passes explicit element ID as a topic key: no ID, or no matching topic reference, yields no selection. This limitation is retained, not described as arbitrary group synchronization. | -| S1 | Instance control context: **Open TOC File** | `WS._WritersideInstanceSelector`, `_openInstanceTree` | App: instance-context Open TOC File assertion; native source opening | Implemented; retained additionally in existing overflow. Native instance-context activation not yet recorded. | -| S1 | Tree source: Ctrl+Shift+Home / Ctrl+Shift+End | `source_commands.dart` line movement, source editor, command registry | A: CRLF, boundaries, reversed selection; source widget: folds, one edit, undo/redo | Implemented only in tree-editor context. Multi-line block behavior is conventional BusyMark behavior, not yet observed in original IDE. | -| S1, S15 | Source authoring: `hidden`, `href`, `toc-title`, nesting | Existing parser plus contextual presentation/refresh | Existing parser/resolver/source/controller suites; W row fixture | Native hidden/external rows captured. No invented Hide/Add External Link menus or search-index parity claim. | -| S8, S13, P | Creation menus: **Topic from Template...**; **Create Topic from Template** dialog | `WritersideTemplateDialog`, `WritersideTemplateService.generate`, guarded `WritersideTopicCreator.create` | TS: resource hashes, six families/10 variants, 22 TGDP topics, literal substitution, XML escaping, safe publication/rollback; TD: RTL/LTR format, validation and creation | Native XML template preview and creation passed; new tab and actual template body verified. BusyMark's preview renderer and conservative filenames are retained. Bundled TGDP collection is pinned, not automatically downloaded. | -| S2, S13, P, user decision | Context: **Save as Template**; toast/dialog: **Edit templates...**; **File and Code Templates**, **Files**, **Internal**, **OK** | `WS._showTopicContextMenu`, `WritersideTemplateService.saveTopic`, `WritersideTemplatesEditor` | TS: original extraction/naming, restart, stale-store rejection, serialized writes, source preservation, built-in reset; TD: staged edits, duplicate/delete, Cancel/OK, conflict recovery | Native save/edit/reuse passed with original topic unchanged. Application-support storage and dedicated editor are user-approved adaptations, not an IDE-wide settings clone. | -| S2, user decision | BusyMark AI, Git, clipboard and file extras follow Writerside actions | `WS._showTocTreeMenu` | App preserves copy/cut/paste, history and applicable Git/AI routes | User-approved layout difference. TOC, Files → Refactor, and flat menus use the existing GTK backend, now with real submenus. A framework fallback is retained only for an unavailable native host; no forced Material path remains on Linux. | - -## Safety and retained differences - -New mutations validate raw source identities and expected content, use guarded -publication, and inspect inactive affected buffers as well as the active document. -Title changes are prepared before a multi-file transaction; dirty-buffer and disk -guards are rechecked at publication, with rollback on partial failure. Topic -removal uses the complete `WritersideProject`: ownership is chosen by exact parsed -topic path or the most-specific configured topic root, so non-active and nested -modules cannot bypass Safe Delete. The reviewed snapshot includes the sorted module -inventory, module inputs, every parsed topic, variables and instance groups, all -semantic `.tree` files, and redirect rules. Hidden, `build`, `target`, `dist`, and -`out` directories participate in semantic discovery; `.git`, `.hg`, and `.svn` -remain hard exclusions. Incomplete module/topic/tree discovery, unparsed topics, -or a changed snapshot fails closed before publication. - -Removal combines indexed links, includes, cards and other semantic references with -an exhaustive tree/start-page pass. `topic`, `ref`, `in`, and `origin` resolve -through the owning host and target modules. Current-instance relevance comes from -the resolved navigation tree and resolved topic documents, including filters and -instance conditions; Safe Delete remains project-wide. Unsupported but resolved -references appear in the approved Review Usages sidebar as manual blockers. Orphan -status is asserted only when the complete post-refactor project has no remaining -usage. - -Redirect plans retain each host instance's effective web filename plus direct and -rule-based accepted aliases, and validate the resolved instance namespace before -writing. Do Refactor resolves dirty project buffers, reanalyzes from disk, preserves -the reviewed options, and applies immediately when blockers are gone. Cross-module -writes use guarded atomic replacement and rollback, with the topic file deleted -last. Clean tabs refresh; unrelated dirty tabs remain untouched. File-monitor -events are deferred during foreground file operations so an own-write event cannot -invalidate the subsequent open-new-document step. - -Structural serialization remains the existing XML pretty-printer: semantic -preservation, not byte-for-byte formatting preservation. Included nodes remain -navigable without enabling unsupported cross-source moves. BusyMark's stricter -mixed-entry sort, safe-delete and front-matter restrictions are explicit above. -The two pictured toolbar chevrons' original tooltips have not been established; -no guessed tooltip names were added. - -Existing-instance Markdown import reuses `discoverMarkdownFiles` for source-root -discovery and the shared import planner for selected topic and referenced-media -copies. Topic targets retain source-root-relative directories beneath the first -configured topics root. Media references use the established Markdown, semantic -XML, Writerside video, and preview-source scan; external/absolute references and -intentionally missing local media retain their prior behavior, and duplicate -media targets are staged once. - -Before planning, and again immediately before publication, the owning module is -reloaded and complete semantic topic discovery is required. Each source basename -passes the shared topic filename validator. IDs are basenames without extensions; -the selected batch must be unique and must not intersect -`WritersideModule.reservedTopicIds`, which includes parsed and discovered-but- -unparsed topic files across all configured topic roots. The import transaction -stages every selected Markdown file, deduplicated media file, and the updated -tree, verifies all source/target snapshots, then publishes once with conservative -rollback. Consequently a stale TOC identity, target change, or concurrent topic -ID leaves no partial topic/media/tree publication. The shared first-topic helper -sets only the first imported reference as `start-page` when appropriate. - -Ordinary empty, template, custom-template, and duplicate creation retains exact -exclusive path creation, then reloads semantic module state after its own topic -exists and before tree publication. A valid final state has exactly one parsed -reservation for the requested ID and that reservation is the candidate path; -another extension, subdirectory, or configured root fails with the existing ID -collision error. Existing owned-file cleanup and final tree snapshot checks remain -independent safeguards. - -## Template implementation and resource provenance - -`assets/writerside/templates.json` contains the installed resources, with each -original archive path and SHA-256 recorded alongside the unmodified source. -It includes six default families (10 Markdown/XML variants) and the 22 Markdown -TGDP entries in the bundled catalog. The bundled TGDP license is retained in -`assets/writerside/TGDP-LICENSE`. No six-name placeholder collection was invented. - -Custom templates and built-in overrides are stored at -`/writerside/templates.json`, not in the documentation -project. Reads create nothing. Writes are serialized with a process file lock -and an isolate queue, compare the editor's original snapshot, reject corrupt or -non-regular stores, and use atomic publication. Cancel does not publish staged -edits or deletions. Save as Template reads the current topic buffer when open, -derives the installed `Writerside_` unique name, substitutes the -installed source patterns, and leaves the topic file unchanged. - -The creation dialog uses the installed Default/Custom/The Good Docs Project -categories, search, title/filename, available Markdown/XML variants, rendered -preview, source link, and template-editing entry points. The editor provides -Files/Internal, new/duplicate/delete/reset, editable source, Cancel, and OK. -GTK owns dropdown presentation; Yaru owns the desktop format radio controls and -editor tabs. The editor is intentionally a scoped application-support editor, -not a clone of every IntelliJ settings component. - -Generation preserves the installed literal substitution contract; unknown tokens, -Velocity directives, and tokens inside replacement values are not evaluated. -XML titles use XML 1.0 escaping. TGDP generation adapts braces, replaces the first -top-level H1, and resolves relative inline link/image destinations against the -bundled v1.2.0 source URL. Repeated destinations are edited once each; code and -prose are not rewritten as links. Exotic/reference-style link source forms are -left unchanged when no unambiguous inline destination is available. There is no -remote TGDP updater or automatic image-file download. Customized malformed XML -and duplicate root IDs are rejected before any topic/tree publication. - -## Native menu integration - -`NativeMenuEntry.submenu` sends a recursive semantic model to the existing -`busymark/native_menus` channel. GTK constructs genuine `GMenu`/`GtkMenu` -submenus; separators and selection IDs retain a shared preorder, including -headings. Only enabled leaf actions can dispatch, and disabled ancestors remain -disabled. GTK 3 does not apply an action's sensitivity to submenu headings, so -heading availability is explicitly applied to the generated GTK widgets in -model order. Limits protect the host from excessive depth/entry counts. - -The existing popup anchoring, session-scoped dismissal, radio groups, and focus -return remain in place. Text direction is carried to each GTK submenu and its -anchor. The Linux menu hierarchy uses the host's keyboard/hover behavior -([GTK MenuShell](https://docs.gtk.org/gtk3/class.MenuShell.html)); it is not a -new Material dropdown implementation. Unit tests assert native-first dispatch, -nested payloads, exact leaf selection, disabled ancestors, and cancellation. -The existing main application menu remains flat. - -## Verification log - -Final verification, including local Markdown topic import, topic removal, -templates, and native menus: - -- `flutter gen-l10n`: exit 0; generated localization sources are current. -- Changed Dart sources and tests were formatted with `dart format`. -- `flutter analyze --no-pub`: exit 0, no issues. -- Full `flutter test --no-pub`: exit 0, **2,074 passed, 58 skipped**. -- Required focused import/topic-creator/template/workspace/controller/TOC/UI - suites, including the focused import dialog: exit 0, **335 passed**. -- `bash tools/validate_writerside_conformance.sh`: exit 0, **181 checks passed**. - This uses the pinned builder 2026.08.0328, distinct from the selected UI plugin - 2026.07.8925. It checks the semantic fixture, not website or menu parity. -- Linux interaction harness: result artifact reports no failure and **50 checks - passed**. - Includes real GTK two-level keyboard traversal in LTR/RTL, disabled headings, - Escape, focus return, session-scoped dismissal, existing flat radio selectors, - both Add Local Markdown Files menu locations, the reviewed removal flow, - mandatory Safe Delete, and the core TOC/template workflows in the action - matrix. -- Normal `flutter build linux --debug --no-pub --target lib/main.dart`: exit 0; - the bundle is restored to the normal application, not the acceptance harness. -- `git diff --check`: exit 0. - -The 58 full-suite skips are optional Typst/PDF/Poppler and D2 integrations that -require explicit tool-path environment configuration. No unavailable optional -integration is counted as a passing TOC or native-menu check. - -The first template full run exposed three test-contract problems: reviewed -technical strings in localization audits, the Chinese locale inheritance -contract, and an offscreen RTL clipboard item after adding Save as Template. -These were corrected and focused checks passed. The native menu run exposed -GTK's submenu-heading sensitivity behavior; an actual disabled-heading assertion -now covers it. A headerbar source audit was narrowed to its real scope (the main -menu stays flat, while TOC context menus may be nested). Existing popup cleanup -and native lifecycle contracts were retained. - -Native fixture: `test/fixtures/writerside/toc_ui/` includes Markdown/XML, -guide/API/library instances, reused titles, groups, hidden/external/included -nodes, and usage references. The harness copies it into a disposable directory -and isolates settings/session/recovery/history/template storage. Only its own -fixture documents are modified; its Safe Delete check deletes only a generated -duplicate, while the original fixture remains untouched. - -Reproduce in an isolated X11 desktop, then restore the normal target: - -```bash -flutter build linux --debug --no-pub --target tools/writerside_toc_visual_smoke.dart -BUSYMARK_NATIVE_PROBE=1 GDK_BACKEND=x11 NO_AT_BRIDGE=0 \ - /usr/bin/dbus-run-session -- /usr/bin/xvfb-run -a -s '-screen 0 1440x1000x24' \ - build/linux/x64/debug/bundle/busymark \ - test/fixtures/writerside/toc_ui /tmp/busymark-gtk-toc-final-acceptance -flutter build linux --debug --no-pub --target lib/main.dart -``` - -The driver requires `/usr/bin/python3`, GTK/AT-SPI introspection, XTest, Xvfb, -and a private D-Bus session. It targets only the probe process. Screen captures -include the actual GTK popup windows, not only Flutter's repaint boundary. -The virtual desktop emits missing portal/GPU and synthetic-trigger warnings, -and AT-SPI/GTK teardown diagnostics were observed during accessibility-driven -menu replacement. All recorded actions completed; this is not a claim of a -warning-free desktop or comprehensive assistive-technology validation. Original IDE -multi-line movement and pictured toolbar-chevron semantics remain unverified -as noted in the matrix. - -Inspected Linux evidence: -[results](/tmp/busymark-gtk-toc-final-acceptance/result.json), -[context menu](/tmp/busymark-gtk-toc-final-acceptance/02-context-and-creation-menu.png), -[creation submenu](/tmp/busymark-gtk-toc-final-acceptance/08-creation-menu.png), -[RTL keyboard submenus](/tmp/busymark-gtk-toc-final-acceptance/19-native-keyboard-rtl.png), -[title dialog](/tmp/busymark-gtk-toc-final-acceptance/03-edit-title.png), -[removal](/tmp/busymark-gtk-toc-final-acceptance/06-removal.png), -[Find](/tmp/busymark-gtk-toc-final-acceptance/07-find-review.png), -[template dialog](/tmp/busymark-gtk-toc-final-acceptance/15-template-dialog.png), -[XML preview](/tmp/busymark-gtk-toc-final-acceptance/16-template-xml-preview.png), -[template editor](/tmp/busymark-gtk-toc-final-acceptance/17-file-and-code-templates.png), -[custom template reuse](/tmp/busymark-gtk-toc-final-acceptance/18-custom-template-preview.png). -The disposable source results remain in `/tmp/busymark-toc-native-SZKSLU`. - -## Review corrections — 2026-09-14 - -Applied to the clean implementation commit `f0ef33f`, without changing GTK -menus or the approved Writerside build/template-storage decisions: - -| Reported defect | Correction | Regression coverage | -| --- | --- | --- | -| Tree-to-editor synchronization depended on the previous document's extension | `_synchronizeToc` opens the selected entry's resolved topic; only topic-less entries use element-source navigation. | `writerside_toc_regressions_test.dart`: open a `.tree` editor, right-select a topic, dismiss the menu, synchronize; then synchronize a topic-less group. | -| Qualified topic references did not synchronize | Resolve each occurrence through the existing module-aware presenter and compare its topic file path with the active file, keeping breadth-first selection. The separately documented `.tree` ID rule is unchanged. | Same widget suite: `guides/install.md`, another `elsewhere/install.md`, a deeper occurrence, and two root occurrences; the first breadth-first matching file wins. | -| Linking the first existing topic omitted `start-page` | Creation and linking share `initializeWritersideFirstTopicHomePage`, applied before insertion within the guarded tree mutation. | `writerside_toc_workspace_test.dart`: empty instance, groups only, existing home, library, empty-group insertion, existing nested topic, and concurrent publication; linked source bytes remain unchanged. | -| Template editor and storage disagreed on uniqueness | Storage validates category/name/extension, matching the editor. Same-category duplicates remain invalid. | `writerside_template_service_test.dart` and `writerside_template_dialogs_test.dart`: custom `Starter.md` and Internal override saved in both orders, persisted and reloaded. | -| Topic creation inherited preview-only mode | Successful `createWritersideTopic` makes only the new preview-only buffer editable, within the controller's file-operation boundary. Existing editable modes, other tabs, and the global preference remain unchanged. | `writerside_toc_regressions_test.dart`: explicitly put the active document and application preference into Reading mode before Empty MD, Empty XML, and template-dialog creation; verify the new topic opens in Source mode while the global preference, existing tab modes, template body, and unrelated dirty content remain unchanged. | - -The new full-workspace widget tests use real temporary project files and the -production controller/services. Only desktop host/theme and persistence services -are isolated. Their framework menu fallback is test-only; these checks do not -replace or claim a new recording of the native GTK evidence above. - -Review verification (Flutter 3.47.5 / Dart 3.13.2): - -- Full `flutter test --no-pub --concurrency=2 --file-reporter - json:/tmp/busymark-toc-review-full.json`: exit 0, **1,921 passed, 58 optional - integration skips**. This includes all 16 new regressions. -- Documented focused suite: exit 0, **416 passed, 1 optional Typst skip**; - `/tmp/busymark-toc-review-focused.json`. -- `flutter analyze --no-pub`: exit 0, no issues. -- `dart format --output=none --set-exit-if-changed` on all nine changed/new Dart - files: exit 0, no changes. `flutter gen-l10n` and `git diff --check`: exit 0. -- `bash tools/validate_writerside_conformance.sh`: exit 0, **181 checks passed**; - `/tmp/busymark-toc-review-builder.log`. -- `flutter build linux --debug --no-pub --target lib/main.dart`: exit 0; - `/tmp/busymark-toc-review-linux-build.log`. The bundle targets the normal app. - -An earlier full-suite attempt emitted an asynchronous local-history file-monitor -teardown error before the user interrupted verification. The isolated -`workspace rename settles a failed first-save promotion` test passed on rerun; -the completed full-suite rerun above also passed. No unrelated local-history or -monitoring behavior was changed to hide that failure. Interrupted processes and -temporary logs were lost, so completed verification was rerun after resuming. - -## Edit Title and dialog-copy review — 2026-09-14 - -This follow-up starts from clean commit `7023934`; the five earlier corrections -remain intact. No GTK menu implementation or approved adaptation was replaced. - -| Finding | Correction and regression evidence | -| --- | --- | -| Literal newlines inside quoted XML attributes had no source range | `_attributeSpans` now matches multiline values. `WritersideTitleEditor` refuses to treat an existing attribute with a missing range as absent, and validates the resulting full XML topic/tree (including duplicate attribute names) before `WorkspaceService` can stage either write. Generated Markdown title elements are also XML-validated. | -| CRLF attributes differed between raw tree identities and normalized editor buffers | Title editing opts into line-ending-normalized identity comparison. Raw structural mutations retain exact matching, and the original file snapshots are still checked before publication. Changed semantic content remains a conflict. | -| Markdown instance titles exposed and re-escaped entity spellings | `_topicTitleOverrides` reads top-level parsed semantic title elements, not raw-source matches. XML entities are decoded once by the parser; the writer still escapes text. Fenced examples are excluded and single-quoted instance attributes work. | -| Removal and advanced title dialog copy differed | English removal now uses `Set redirect to:`, `{count} usages found.`, and the pictured confirmation paragraph. Advanced title settings show the two inheritance explanations beneath their fields and a working documentation link. All 23 maintained ARBs were updated and localizations regenerated. | - -`writerside_title_regressions_test.dart` covers LF/CRLF and both quote styles, -changing and clearing overrides, preserved unrelated attributes/content, -missing/corrupt ranges with neither file published, duplicate XML attributes, -semantic title extraction, and actual save–reopen–edit dialog workflows. It also -checks unchanged OK and documentation-link activation. The live removal dialog -copy and cancellation are checked in `writerside_toc_regressions_test.dart`. - -Copy evidence: the official [instance-title screenshot](https://resources.jetbrains.com/help/img/writerside/edit_title_instance_specific.png), -[TOC-title screenshot](https://resources.jetbrains.com/help/img/writerside/edit_title_toc_title.png), -and [removal screenshot](https://resources.jetbrains.com/help/img/writerside/remove_topic_dialog.png), -cross-checked against installed 2026.07.8925 `EditTitleDialog.comment.*` and -`RemoveTocElementDialog.*` bundle entries. The usage-count period follows the -specified screenshot (the installed bundle omits it). The documentation link -opens the current [Topics page](https://www.jetbrains.com/help/writerside/topics.html), -which contains title inheritance and overrides; the installed dialog's older -`changing-topic-title.html` URL was not retrievable. - -This corrects the reported functional defects and specified copy gaps, not all -original-product presentation differences. The two unidentified icon-only -toolbar controls remain unimplemented/unverified; no guessed controls or names -were added. The existing removal promotion, mixed-XML sorting restriction, -BusyMark preview renderer, approved template editor/storage and Find sidebar -remain explicitly documented adaptations. Full original-Writerside parity is -not claimed. Translations are BusyMark translations, not verified original -JetBrains locale strings. - -Verification for this follow-up (Flutter 3.47.5 / Dart 3.13.2): - -- Full `flutter test --no-pub --concurrency=2 --file-reporter json:/tmp/busymark-title-full.json`: - exit 0, **1,938 passed, 58 optional integration skips**; 17 new regressions. -- Documented Writerside/source/controller/export suite, with `--concurrency=2`: - exit 0, **433 passed, 1 optional Typst skip**; - `/tmp/busymark-title-focused-suite.json`. -- `flutter analyze --no-pub`: exit 0, no issues. Localization generation, - formatting checks on nine non-generated Dart files, and `git diff --check`: - exit 0. -- `bash tools/validate_writerside_conformance.sh`: exit 0, **181 checks passed**; - `/tmp/busymark-title-builder.log`. -- Native Linux harness: exit 0, **36 checks passed, 20 captures**; - [result](/tmp/busymark-title-native-review/result.json). The production - [advanced title dialog](/tmp/busymark-title-native-review/03-edit-title.png) - and [removal dialog](/tmp/busymark-title-native-review/06-removal.png) were - visually inspected. Real GTK menus and their keyboard/focus tests passed. - The private desktop emitted portal, synthetic-popup and D-Bus teardown - diagnostics; this is not a warning-free desktop claim. Only the disposable - `/tmp/busymark-toc-native-ZIOBPT` fixture was mutated. -- `flutter build linux --debug --no-pub --target lib/main.dart`: exit 0; - `/tmp/busymark-title-linux-build.log`. The bundle was restored to the normal - application after the harness run. diff --git a/docs/development/writerside-verification.md b/docs/development/writerside-verification.md index cdbc1fd1..8617d236 100644 --- a/docs/development/writerside-verification.md +++ b/docs/development/writerside-verification.md @@ -4,6 +4,46 @@ The user-facing [Writerside support](../writerside-support.md) page describes th supported semantic surface and known limitations. This note contains the maintainer checks behind those claims. +## Mutation and resource constraints + +TOC changes validate source identities and expected disk content at publication. +Affected inactive buffers participate in dirty-buffer checks. Multi-file title +edits and refactoring use guarded publication and rollback; file-monitor events +are deferred during foreground file operations. Structural serialization +preserves semantics, not original XML whitespace. + +`WritersideTopicRemovalService` analyzes the complete project, including +non-active and nested modules. Its snapshot covers module discovery, topic and +tree sources, variables, instance groups and redirect rules. Hidden and ordinary +build/output directories participate in semantic discovery; `.git`, `.hg` and +`.svn` remain excluded. Incomplete discovery, unparsed topics or changed inputs +block publication. Ownership follows parsed paths or the most-specific containing +module with a configured topic root. Safe Delete is mandatory for Writerside +topics, and unresolved semantic references remain manual blockers in Find. +Apply reanalyzes the project, +publishes guarded updates and deletes the topic last; rollback must not +overwrite another process's changes. + +Creation and Markdown import require complete topic discovery and reserve IDs +across every configured topic root, including unparsed files. Import stages +selected topics, deduplicated referenced media and tree changes together, +rechecks snapshots and rolls back on failure. Creation uses exclusive file +creation and rechecks semantic ID ownership before publishing the tree. A +concurrent collision must not leave a partial topic/media/tree publication. + +`assets/writerside/templates.json` retains each bundled resource's archive path, +source and SHA-256; keep `assets/writerside/TGDP-LICENSE` with it. Template-store +writes use an isolate queue, process file lock, expected snapshot and atomic +publication. Reject corrupt or non-regular stores. Names are unique within +category and extension; generation does not evaluate Velocity. Validate XML and +root IDs before topic publication. + +Linux TOC menus use the existing `busymark/native_menus` GTK channel. Only +enabled leaves may dispatch, including under disabled ancestors. GTK submenu +heading sensitivity is applied explicitly; depth and entry limits, RTL, +anchoring, dismissal and focus return remain part of the contract. The main +application menu remains flat. + ## Automated coverage The Writerside tests cover resolution, source preservation, anchored file @@ -22,6 +62,37 @@ flutter test --no-pub test/src/writerside*_test.dart \ test/src/markdown_export_mapper_test.dart ``` +The TOC action/workspace/editor, topic-creation/removal, template and title/TOC +regression suites cover these publication constraints and the documented +[authoring limitations](../writerside-instances.md#toc-authoring-and-limitations). +Widget menu fallbacks do not establish native GTK behavior. + +## Linux TOC acceptance harness + +`tools/writerside_toc_visual_smoke.dart` exercises production controllers, +widgets and real GTK menus, including keyboard traversal, RTL, disabled +headings, dismissal, focus return, TOC operations and template workflows. It +copies `test/fixtures/writerside/toc_ui` into a temporary workspace and isolates +settings, session, recovery, history and template storage. + +Run from the repository root with Python GTK/AT-SPI introspection, XTest, Xvfb +and D-Bus installed. Keep captures and results outside the repository: + +```bash +toc_output="$(mktemp -d)" +flutter build linux --debug --no-pub --target tools/writerside_toc_visual_smoke.dart +BUSYMARK_NATIVE_PROBE=1 GDK_BACKEND=x11 NO_AT_BRIDGE=0 \ + dbus-run-session -- xvfb-run -a -s '-screen 0 1440x1000x24' \ + build/linux/x64/debug/bundle/busymark \ + test/fixtures/writerside/toc_ui "$toc_output" +flutter build linux --debug --no-pub --target lib/main.dart +``` + +The harness exits nonzero on failure and writes `result.json` with its checks +and temporary fixture path. Remove the output and disposable fixture after +inspection. It verifies BusyMark's native interactions, not visual parity with +the original IDE or comprehensive assistive-technology support. + ## Official-builder conformance `test/fixtures/writerside/conformance_semantics.json` is a normalized semantic diff --git a/docs/screenshots/busymark-welcome.png b/docs/screenshots/busymark-welcome.png deleted file mode 100644 index 743d9d3a..00000000 Binary files a/docs/screenshots/busymark-welcome.png and /dev/null differ diff --git a/docs/snap-confinement.md b/docs/snap-confinement.md index 567e6b6b..882b7238 100644 --- a/docs/snap-confinement.md +++ b/docs/snap-confinement.md @@ -16,10 +16,13 @@ sudo snap connect busymark:ssh-keys sudo snap connect busymark:password-manager-service ``` -BusyMark's AI provider keys use the system credential store. Saving, reading, -or removing those keys in a strict Snap requires the second connection. It -grants access to the session's password manager, so make that choice only on a -trusted installation. +BusyMark stores AI provider keys and Nextcloud Notes app passwords in the system +credential store through libsecret. Saving, reading, or removing either kind of +credential in a strict Snap requires the `password-manager-service` connection. +It grants access to the session's password manager, so make that choice only on a +trusted installation. Nextcloud credentials have a separate namespace; an +unavailable or locked credential store has no plaintext fallback. See +[Nextcloud Notes](development/nextcloud-notes.md) for the authentication contract. ## Git author identity diff --git a/docs/writerside-instances.md b/docs/writerside-instances.md index 1b0d7d81..a679b63c 100644 --- a/docs/writerside-instances.md +++ b/docs/writerside-instances.md @@ -22,8 +22,9 @@ The instance editor writes the documented locations: - `noindex-content` and `offline-docs` in the configured `buildprofiles.xml`. -An empty regular instance is valid. Its first newly created topic becomes its -`start-page`. A TOC library is written with `is-library="true"` and does not +An empty regular instance is valid. Its first created, linked, or imported topic +becomes its `start-page` when no home page is already set. Empty groups do not +count as topics. A TOC library is written with `is-library="true"` and does not have output settings of its own. Changing an instance ID renames its `.tree` file and updates documented project @@ -54,6 +55,61 @@ identified, but are not expanded when only one help module is open. The selected instance and icon colors are local BusyMark preferences. They do not modify or add undocumented Writerside project metadata. +## TOC authoring and limitations + +The native Linux context menus provide topic creation, linking, duplication, +title editing, grouping, sorting, removal, and source navigation. BusyMark's +AI, Git, clipboard and file actions follow the Writerside actions. Reading and +Split remain the document preview modes; there is no separate Writerside +Preview tool window or claim of complete original IDE presentation parity. + +- **Add Local Markdown Files** imports selected files as siblings from the + context menu or as roots from the header. It is absent from **New Child + Topic**. Imports preserve Markdown bytes and source-relative directories and + copy referenced local media. Topic IDs must be unique across all configured + topic roots, including discovered files that cannot be parsed. +- **Duplicate** copies the topic body into a new sibling reference, with a new + XML root ID where applicable; it does not copy TOC descendants. Newly created + topics open for editing without changing the global document-view preference. +- **Edit Title** manages the base title, instance override and TOC-only override + independently. Blank overrides restore inheritance. Markdown base-title edits + change the top-level H1 and preserve front matter. +- **Group** requires siblings and preserves their source order and subtrees. + **Sort Child Topics Alphabetically** sorts only immediate children by their + resolved, case-sensitive titles. Mixed XML children, such as includes alongside + TOC elements, are rejected rather than discarded. +- **Remove TOC Element** promotes its direct children. File deletion always + requires **Safe Delete**, including from the Files menu. **Review Usages** + opens the **Find** sidebar; **Do Refactor** rechecks references before applying + changes. Unresolved references can require manual edits before deletion. +- **Synchronize TOC and Editor** opens the selected topic or its element source + when invoked from the tree. From a topic editor it selects the first matching + resolved file in breadth-first order. From a `.tree` editor, the element's + explicit ID is matched as a topic reference; missing IDs or unmatched + references leave the selection unchanged. + +Structural edits preserve XML semantics but may reformat the tree. Included +nodes retain the source ownership restrictions described above. BusyMark does +not reproduce the original IDE's unidentified toolbar chevrons or claim its +multi-line tree movement behaves identically to that IDE. + +## Topic templates + +**Topic from Template** offers Default, Custom and The Good Docs Project +templates, Markdown/XML variants where available, and a rendered preview. +**Save as Template** uses the current topic buffer without changing the topic. +Custom templates and built-in overrides are stored in BusyMark's application +support directory at `writerside/templates.json`. **File and Code Templates** +stages edits until OK; Cancel leaves storage unchanged. These preferences add no +project metadata. + +Template substitution is literal: `${TITLE}` and `${ID}` are replaced once, +with XML title escaping where needed. Velocity expressions and tokens inside +replacement values are not executed. The bundled TGDP catalog is pinned; +relative inline links resolve against its source URL, but there is no automatic +catalog update or image download. Ambiguous and reference-style links remain +unchanged. Preview uses BusyMark's renderer. + ## Authoritative references - [Instances](https://www.jetbrains.com/help/writerside/instances.html) diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index f91acbab..8489ba65 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -1,4 +1,6 @@ { + "closeOtherTabs": "إغلاق علامات التبويب الأخرى", + "@closeOtherTabs": {"description": "Document tab context menu action that closes every other document and comparison tab while retaining this document."}, "tocTopicFromTemplate": "موضوع من قالب...", "tocSaveAsTemplate": "حفظ كقالب", "tocTemplateDialog": "إنشاء موضوع من قالب", @@ -1877,9 +1879,9 @@ "@errorWritersideTocInvalidMove": { "description": "Detail shown when a Writerside TOC move would create a cycle." }, - "errorWritersideStartTopicDelete": "لا يمكن حذف موضوع البدء ⁨{topic}⁩. اختر صفحة بدء أخرى أولًا.", + "errorWritersideStartTopicDelete": "لا يمكن حذف الصفحة الرئيسية {topic}. اختر صفحة رئيسية أخرى أولًا.", "@errorWritersideStartTopicDelete": { - "description": "Detail shown when attempting to delete the start topic of a Writerside instance.", + "description": "Detail shown when attempting to delete the Home Page of a Writerside instance.", "placeholders": { "topic": { "type": "String" @@ -2669,7 +2671,11 @@ } } }, - "topicIsStartPageRemovalWarning": "يُستخدم هذا الموضوع كصفحة بدء لمثيل. راجع استخداماته وعيّن صفحة بدء أخرى قبل المتابعة.", + "topicIsHomePageRemovalWarning": "هذا الموضوع هو الصفحة الرئيسية لهذا المثيل. اختر صفحة رئيسية أخرى قبل إزالته من جدول المحتويات.", + "newHomePage": "الصفحة الرئيسية الجديدة", + "chooseHomePage": "اختر صفحة رئيسية", + "noHomePageReplacement": "أضف موضوعًا مؤهلًا آخر إلى هذا المثيل أو اربطه به قبل إزالة صفحته الرئيسية.", + "topicIsStartPageRemovalWarning": "هذا الموضوع هو الصفحة الرئيسية لمثيل. عيّن صفحة رئيسية أخرى قبل حذف ملفه المصدر.", "@topicIsStartPageRemovalWarning": { "description": "Warning shown when the topic is used as an instance start page." }, @@ -2694,7 +2700,7 @@ "@topicUsageTocElements": { "description": "Usage group label for TOC elements." }, - "topicUsageStartPages": "صفحات البدء", + "topicUsageStartPages": "الصفحات الرئيسية", "@topicUsageStartPages": { "description": "Usage group label for instance start pages." }, @@ -3406,5 +3412,12 @@ "nextcloudAttachmentReference": "مرجع المرفق على الخادم", "nextcloudAddAttachment": "إضافة مرفق…", "nextcloudDeleteAttachmentWarning": "سيؤدي حذف المرفق إلى جعل روابطه في الملاحظة غير متاحة. يمكن استعادة البيانات المحتفَظ بها من السجل المحلي.", - "nextcloudChooseConflictingAttributes": "اختر قيمة لكل سمة متعارضة." + "nextcloudChooseConflictingAttributes": "اختر قيمة لكل سمة متعارضة.", + "nextcloudUseServerNote": "استخدام هذه الملاحظة على الخادم", + "nextcloudUseServerNoteExplanation": "يربط هذه المسودة بالملاحظة المحددة على الخادم، ثم يزامن التغييرات المحلية المحفوظة. تطابق الطلب لا يؤكد من أنشأ الملاحظة.", + "nextcloudExactCandidate": "تطابق تام مع الطلب؛ الهوية غير مؤكدة.", + "nextcloudPossibleCandidate": "تطابق محتمل بعد تنقية الطلب؛ الهوية غير مؤكدة.", + "nextcloudSelectCandidate": "اختر ملاحظة على الخادم لمراجعتها", + "nextcloudCreateSeparate": "إنشاء ملاحظة منفصلة", + "nextcloudCreateSeparateWarning": "قد يكون الطلب الأول قد أنشأ ملاحظة على الخادم بالفعل. ينشئ هذا ملاحظة منفصلة بعملك المحلي المحفوظ. لن تُحذف أي ملاحظة يُشتبه بأنها مكررة." } diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index c4f3c6b8..7f2644d0 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1,4 +1,6 @@ { + "closeOtherTabs": "Andere Tabs schließen", + "@closeOtherTabs": {"description": "Document tab context menu action that closes every other document and comparison tab while retaining this document."}, "tocTopicFromTemplate": "Thema aus Vorlage...", "tocSaveAsTemplate": "Als Vorlage speichern", "tocTemplateDialog": "Thema aus Vorlage erstellen", @@ -1877,9 +1879,9 @@ "@errorWritersideTocInvalidMove": { "description": "Detail shown when a Writerside TOC move would create a cycle." }, - "errorWritersideStartTopicDelete": "Das Startthema {topic} kann nicht gelöscht werden. Wählen Sie zuerst eine andere Startseite aus.", + "errorWritersideStartTopicDelete": "Die Startseite {topic} kann nicht gelöscht werden. Wählen Sie zuerst eine andere Startseite.", "@errorWritersideStartTopicDelete": { - "description": "Detail shown when attempting to delete the start topic of a Writerside instance.", + "description": "Detail shown when attempting to delete the Home Page of a Writerside instance.", "placeholders": { "topic": { "type": "String" @@ -2669,7 +2671,11 @@ } } }, - "topicIsStartPageRemovalWarning": "Dieses Thema wird als Startseite einer Instanz verwendet. Prüfen Sie seine Verwendungen und weisen Sie eine andere Startseite zu, bevor Sie fortfahren.", + "topicIsHomePageRemovalWarning": "Dieses Thema ist die Startseite dieser Instanz. Wählen Sie eine andere Startseite, bevor Sie es aus dem Inhaltsverzeichnis entfernen.", + "newHomePage": "Neue Startseite", + "chooseHomePage": "Startseite auswählen", + "noHomePageReplacement": "Fügen Sie dieser Instanz ein anderes geeignetes Thema hinzu oder verknüpfen Sie es, bevor Sie ihre Startseite entfernen.", + "topicIsStartPageRemovalWarning": "Dieses Thema ist die Startseite einer Instanz. Legen Sie eine andere Startseite fest, bevor Sie seine Quelldatei löschen.", "@topicIsStartPageRemovalWarning": { "description": "Warning shown when the topic is used as an instance start page." }, @@ -3406,5 +3412,12 @@ "nextcloudAttachmentReference": "Anhangreferenz auf dem Server", "nextcloudAddAttachment": "Anhang hinzufügen…", "nextcloudDeleteAttachmentWarning": "Durch Löschen des Anhangs sind seine Links in der Notiz nicht mehr verfügbar. Aufbewahrte Daten können über den lokalen Verlauf wiederhergestellt werden.", - "nextcloudChooseConflictingAttributes": "Wählen Sie jeden widersprüchlichen Wert." + "nextcloudChooseConflictingAttributes": "Wählen Sie jeden widersprüchlichen Wert.", + "nextcloudUseServerNote": "Diese Servernotiz verwenden", + "nextcloudUseServerNoteExplanation": "Verknüpft diesen Entwurf mit der ausgewählten Servernotiz und synchronisiert anschließend die behaltenen lokalen Änderungen. Ein passender Antrag bestätigt nicht, wer die Notiz erstellt hat.", + "nextcloudExactCandidate": "Exakte Übereinstimmung mit der Anfrage; Identität unbestätigt.", + "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." } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 37a8033a..30936f30 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1,4 +1,6 @@ { + "closeOtherTabs": "Close other tabs", + "@closeOtherTabs": {"description": "Document tab context menu action that closes every other document and comparison tab while retaining this document."}, "tocTopicFromTemplate": "Topic from Template...", "@tocTopicFromTemplate": {"description": "Writerside template dialog or user-template storage feedback."}, "tocSaveAsTemplate": "Save as Template", @@ -962,8 +964,16 @@ "description": "Disclosure that children of a removed TOC element are promoted.", "placeholders": {"count": {"type": "int"}} }, - "topicIsStartPageRemovalWarning": "This topic is used as an instance start page. Review its usages and assign another start page before continuing.", - "@topicIsStartPageRemovalWarning": {"description": "Warning that blocks deletion of a Writerside start-page topic."}, + "topicIsHomePageRemovalWarning": "This topic is the Home Page for this instance. Choose another Home Page before removing it from the TOC.", + "@topicIsHomePageRemovalWarning": {"description": "Home Page replacement in the Remove TOC Element dialog."}, + "newHomePage": "New Home Page", + "@newHomePage": {"description": "Home Page replacement in the Remove TOC Element dialog."}, + "chooseHomePage": "Choose a Home Page", + "@chooseHomePage": {"description": "Home Page replacement in the Remove TOC Element dialog."}, + "noHomePageReplacement": "Add or link another eligible topic to this instance before removing its Home Page.", + "@noHomePageReplacement": {"description": "Home Page replacement in the Remove TOC Element dialog."}, + "topicIsStartPageRemovalWarning": "This topic is a Home Page for an instance. Assign another Home Page before deleting its source file.", + "@topicIsStartPageRemovalWarning": {"description": "Warning that blocks Safe Delete of a Writerside Home Page."}, "topicUsagesCount": "{count} usages found.", "@topicUsagesCount": { "description": "Sentence reporting the number of Writerside topic usages in the removal dialog.", @@ -975,8 +985,8 @@ "@topicUsagesFound": {"description": "Description above Writerside topic usage groups."}, "topicUsageTocElements": "TOC elements", "@topicUsageTocElements": {"description": "Usage group label for Writerside TOC elements."}, - "topicUsageStartPages": "Start pages", - "@topicUsageStartPages": {"description": "Usage group label for Writerside instance start pages."}, + "topicUsageStartPages": "Home Pages", + "@topicUsageStartPages": {"description": "Usage group label for Writerside instance Home Pages."}, "topicUsageTopicLinks": "Topic links", "@topicUsageTopicLinks": {"description": "Usage group label for links to a Writerside topic."}, "topicUsageIncludes": "Includes", @@ -1397,9 +1407,9 @@ "@errorWritersideTocNodeMissing": {"description": "Detail shown when a selected Writerside TOC entry no longer exists."}, "errorWritersideTocInvalidMove": "A TOC entry cannot be moved into itself or one of its children.", "@errorWritersideTocInvalidMove": {"description": "Detail shown when a Writerside TOC move would create a cycle."}, - "errorWritersideStartTopicDelete": "The start topic {topic} cannot be deleted. Choose another start page first.", + "errorWritersideStartTopicDelete": "The Home Page {topic} cannot be deleted. Choose another Home Page first.", "@errorWritersideStartTopicDelete": { - "description": "Detail shown when attempting to delete the start topic of a Writerside instance.", + "description": "Detail shown when attempting to delete the Home Page of a Writerside instance.", "placeholders": {"topic": {"type": "String"}} }, "errorWritersideSafeDeleteRequired": "Use Safe Delete for Writerside topic files.", @@ -2839,5 +2849,12 @@ "nextcloudAttachmentReference": "Attachment reference on server", "nextcloudAddAttachment": "Add attachment…", "nextcloudDeleteAttachmentWarning": "Deleting this attachment makes its links in the note unavailable. Retained bytes can be recovered through local history.", - "nextcloudChooseConflictingAttributes": "Choose each conflicting attribute." + "nextcloudChooseConflictingAttributes": "Choose each conflicting attribute.", + "nextcloudUseServerNote": "Use this server note", + "nextcloudUseServerNoteExplanation": "Links this draft to the selected server note, then synchronizes retained local changes. A matching request does not confirm who created the note.", + "nextcloudExactCandidate": "Exact request match; identity is unconfirmed.", + "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." } diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 5357455e..919eb98b 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -1,4 +1,6 @@ { + "closeOtherTabs": "Cerrar las otras pestañas", + "@closeOtherTabs": {"description": "Document tab context menu action that closes every other document and comparison tab while retaining this document."}, "tocTopicFromTemplate": "Tema desde plantilla...", "tocSaveAsTemplate": "Guardar como plantilla", "tocTemplateDialog": "Crear tema desde plantilla", @@ -1877,9 +1879,9 @@ "@errorWritersideTocInvalidMove": { "description": "Detail shown when a Writerside TOC move would create a cycle." }, - "errorWritersideStartTopicDelete": "El tema de inicio {topic} no se puede eliminar. Elija primero otra página de inicio.", + "errorWritersideStartTopicDelete": "No se puede eliminar la página de inicio {topic}. Elige otra página de inicio primero.", "@errorWritersideStartTopicDelete": { - "description": "Detail shown when attempting to delete the start topic of a Writerside instance.", + "description": "Detail shown when attempting to delete the Home Page of a Writerside instance.", "placeholders": { "topic": { "type": "String" @@ -2669,7 +2671,11 @@ } } }, - "topicIsStartPageRemovalWarning": "Este tema se usa como página de inicio de una instancia. Revisa sus usos y asigna otra página de inicio antes de continuar.", + "topicIsHomePageRemovalWarning": "Este tema es la página de inicio de esta instancia. Elige otra página de inicio antes de quitarlo de la tabla de contenido.", + "newHomePage": "Nueva página de inicio", + "chooseHomePage": "Elegir página de inicio", + "noHomePageReplacement": "Añade o vincula otro tema válido a esta instancia antes de quitar su página de inicio.", + "topicIsStartPageRemovalWarning": "Este tema es la página de inicio de una instancia. Asigna otra página de inicio antes de eliminar su archivo de origen.", "@topicIsStartPageRemovalWarning": { "description": "Warning shown when the topic is used as an instance start page." }, @@ -3406,5 +3412,12 @@ "nextcloudAttachmentReference": "Referencia del archivo adjunto en el servidor", "nextcloudAddAttachment": "Añadir archivo adjunto…", "nextcloudDeleteAttachmentWarning": "Al eliminar este archivo adjunto, sus enlaces en la nota dejarán de estar disponibles. Los datos conservados se pueden recuperar mediante el historial local.", - "nextcloudChooseConflictingAttributes": "Elige cada atributo en conflicto." + "nextcloudChooseConflictingAttributes": "Elige cada atributo en conflicto.", + "nextcloudUseServerNote": "Usar esta nota del servidor", + "nextcloudUseServerNoteExplanation": "Vincula este borrador con la nota seleccionada del servidor y luego sincroniza los cambios locales conservados. Una solicitud coincidente no confirma quién creó la nota.", + "nextcloudExactCandidate": "Coincidencia exacta con la solicitud; identidad sin confirmar.", + "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." } diff --git a/lib/l10n/app_et.arb b/lib/l10n/app_et.arb index 56483d2c..22fbad6f 100644 --- a/lib/l10n/app_et.arb +++ b/lib/l10n/app_et.arb @@ -1,4 +1,6 @@ { + "closeOtherTabs": "Sulge teised vahelehed", + "@closeOtherTabs": {"description": "Document tab context menu action that closes every other document and comparison tab while retaining this document."}, "tocTopicFromTemplate": "Teema mallist...", "tocSaveAsTemplate": "Salvesta mallina", "tocTemplateDialog": "Loo teema mallist", @@ -1584,9 +1586,13 @@ } } }, - "topicIsStartPageRemovalWarning": "Seda teemat kasutatakse eksemplari avalehena. Enne jätkamist vaata kasutuskohad üle ja määra teine avaleht.", + "topicIsHomePageRemovalWarning": "See teema on selle eksemplari avaleht. Vali teine avaleht enne selle eemaldamist sisukorrast.", + "newHomePage": "Uus avaleht", + "chooseHomePage": "Vali avaleht", + "noHomePageReplacement": "Lisa või lingi sellele eksemplarile teine sobiv teema enne selle avalehe eemaldamist.", + "topicIsStartPageRemovalWarning": "See teema on eksemplari avaleht. Määra teine avaleht enne selle lähtefaili kustutamist.", "@topicIsStartPageRemovalWarning": { - "description": "Warning that blocks deletion of a Writerside start-page topic." + "description": "Warning that blocks Safe Delete of a Writerside Home Page." }, "topicUsagesCount": "Leiti {count} kasutuskohta.", "@topicUsagesCount": { @@ -1611,7 +1617,7 @@ }, "topicUsageStartPages": "Avalehed", "@topicUsageStartPages": { - "description": "Usage group label for Writerside instance start pages." + "description": "Usage group label for Writerside instance Home Pages." }, "topicUsageTopicLinks": "Teemalingid", "@topicUsageTopicLinks": { @@ -2213,9 +2219,9 @@ "@errorWritersideTocInvalidMove": { "description": "Detail shown when a Writerside TOC move would create a cycle." }, - "errorWritersideStartTopicDelete": "Avaleheks määratud teemat „{topic}” ei saa kustutada. Vali esmalt teine avaleht.", + "errorWritersideStartTopicDelete": "Avaleht {topic} ei saa kustutada. Vali esmalt teine avaleht.", "@errorWritersideStartTopicDelete": { - "description": "Detail shown when attempting to delete the start topic of a Writerside instance.", + "description": "Detail shown when attempting to delete the Home Page of a Writerside instance.", "placeholders": { "topic": { "type": "String" @@ -3866,5 +3872,12 @@ "nextcloudAttachmentReference": "Manuse viide serveris", "nextcloudAddAttachment": "Lisa manus…", "nextcloudDeleteAttachmentWarning": "Manuse kustutamine muudab selle lingid märkmes kättesaamatuks. Säilitatud andmed saab taastada kohalikust ajaloost.", - "nextcloudChooseConflictingAttributes": "Valige iga vastuolulise atribuudi väärtus." + "nextcloudChooseConflictingAttributes": "Valige iga vastuolulise atribuudi väärtus.", + "nextcloudUseServerNote": "Kasuta seda serverimärkust", + "nextcloudUseServerNoteExplanation": "Seob mustandi valitud serverimärkusega ja sünkroonib seejärel säilitatud kohalikud muudatused. Päringu vastavus ei kinnita märkuse loojat.", + "nextcloudExactCandidate": "Täpne päringu vaste; identiteet on kinnitamata.", + "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." } diff --git a/lib/l10n/app_fa.arb b/lib/l10n/app_fa.arb index febd1173..b880d7cc 100644 --- a/lib/l10n/app_fa.arb +++ b/lib/l10n/app_fa.arb @@ -1,4 +1,6 @@ { + "closeOtherTabs": "بستن زبانه‌های دیگر", + "@closeOtherTabs": {"description": "Document tab context menu action that closes every other document and comparison tab while retaining this document."}, "tocTopicFromTemplate": "موضوع از الگو...", "tocSaveAsTemplate": "ذخیره به‌عنوان الگو", "tocTemplateDialog": "ایجاد موضوع از الگو", @@ -1885,9 +1887,9 @@ "@errorWritersideTocInvalidMove": { "description": "Detail shown when a Writerside TOC move would create a cycle." }, - "errorWritersideStartTopicDelete": "موضوع شروع ⁨{topic}⁩ را نمی‌توان حذف کرد. ابتدا صفحهٔ شروع دیگری را انتخاب کنید.", + "errorWritersideStartTopicDelete": "صفحهٔ اصلی {topic} قابل حذف نیست. ابتدا صفحهٔ اصلی دیگری انتخاب کنید.", "@errorWritersideStartTopicDelete": { - "description": "Detail shown when attempting to delete the start topic of a Writerside instance.", + "description": "Detail shown when attempting to delete the Home Page of a Writerside instance.", "placeholders": { "topic": { "type": "String" @@ -2709,7 +2711,11 @@ } } }, - "topicIsStartPageRemovalWarning": "این موضوع به‌عنوان صفحهٔ آغاز یک نمونه استفاده می‌شود. موارد استفاده را مرور و پیش از ادامه صفحهٔ آغاز دیگری تعیین کنید.", + "topicIsHomePageRemovalWarning": "این موضوع صفحهٔ اصلی این نمونه است. پیش از حذف آن از فهرست مطالب، صفحهٔ اصلی دیگری انتخاب کنید.", + "newHomePage": "صفحهٔ اصلی جدید", + "chooseHomePage": "انتخاب صفحهٔ اصلی", + "noHomePageReplacement": "پیش از حذف صفحهٔ اصلی، موضوع مناسب دیگری به این نمونه اضافه یا پیوند دهید.", + "topicIsStartPageRemovalWarning": "این موضوع صفحهٔ اصلی یک نمونه است. پیش از حذف فایل منبع آن، صفحهٔ اصلی دیگری تعیین کنید.", "@topicIsStartPageRemovalWarning": { "description": "Warning shown when the topic is used as an instance start page." }, @@ -2735,7 +2741,7 @@ "@topicUsageTocElements": { "description": "Usage group label for TOC elements." }, - "topicUsageStartPages": "صفحه‌های آغاز", + "topicUsageStartPages": "صفحه‌های اصلی", "@topicUsageStartPages": { "description": "Usage group label for instance start pages." }, @@ -3448,5 +3454,12 @@ "nextcloudAttachmentReference": "مرجع پیوست روی سرور", "nextcloudAddAttachment": "افزودن پیوست…", "nextcloudDeleteAttachmentWarning": "با حذف این پیوست، پیوندهای آن در یادداشت دیگر در دسترس نخواهند بود. داده‌های نگه‌داری‌شده را می‌توان از تاریخچهٔ محلی بازیابی کرد.", - "nextcloudChooseConflictingAttributes": "مقدار هر ویژگی متعارض را انتخاب کنید." + "nextcloudChooseConflictingAttributes": "مقدار هر ویژگی متعارض را انتخاب کنید.", + "nextcloudUseServerNote": "استفاده از این یادداشت سرور", + "nextcloudUseServerNoteExplanation": "این پیش‌نویس را به یادداشت انتخاب‌شدهٔ سرور پیوند می‌دهد و سپس تغییرات محلی حفظ‌شده را همگام می‌کند. تطابق درخواست مشخص نمی‌کند چه کسی یادداشت را ایجاد کرده است.", + "nextcloudExactCandidate": "تطابق دقیق درخواست؛ هویت تأیید نشده است.", + "nextcloudPossibleCandidate": "تطابق احتمالی درخواست پاک‌سازی‌شده؛ هویت تأیید نشده است.", + "nextcloudSelectCandidate": "یک یادداشت سرور را برای بررسی انتخاب کنید", + "nextcloudCreateSeparate": "ایجاد یک یادداشت جداگانه", + "nextcloudCreateSeparateWarning": "ممکن است درخواست نخست قبلاً یک یادداشت سرور ایجاد کرده باشد. این کار یک یادداشت جداگانه با کار محلی حفظ‌شدهٔ شما ایجاد می‌کند. هیچ یادداشت مشکوک به تکراری بودن حذف نمی‌شود." } diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index d57a49c9..608257f8 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -1,4 +1,6 @@ { + "closeOtherTabs": "Fermer les autres onglets", + "@closeOtherTabs": {"description": "Document tab context menu action that closes every other document and comparison tab while retaining this document."}, "tocTopicFromTemplate": "Sujet à partir d’un modèle...", "tocSaveAsTemplate": "Enregistrer comme modèle", "tocTemplateDialog": "Créer un sujet à partir d’un modèle", @@ -1877,9 +1879,9 @@ "@errorWritersideTocInvalidMove": { "description": "Detail shown when a Writerside TOC move would create a cycle." }, - "errorWritersideStartTopicDelete": "Le sujet de démarrage {topic} ne peut pas être supprimé. Choisissez d’abord une autre page de démarrage.", + "errorWritersideStartTopicDelete": "La page d’accueil {topic} ne peut pas être supprimée. Choisissez d’abord une autre page d’accueil.", "@errorWritersideStartTopicDelete": { - "description": "Detail shown when attempting to delete the start topic of a Writerside instance.", + "description": "Detail shown when attempting to delete the Home Page of a Writerside instance.", "placeholders": { "topic": { "type": "String" @@ -2669,7 +2671,11 @@ } } }, - "topicIsStartPageRemovalWarning": "Ce sujet sert de page de démarrage à une instance. Examinez ses utilisations et attribuez une autre page de démarrage avant de continuer.", + "topicIsHomePageRemovalWarning": "Ce sujet est la page d’accueil de cette instance. Choisissez une autre page d’accueil avant de le retirer de la table des matières.", + "newHomePage": "Nouvelle page d’accueil", + "chooseHomePage": "Choisir une page d’accueil", + "noHomePageReplacement": "Ajoutez ou liez un autre sujet admissible à cette instance avant de retirer sa page d’accueil.", + "topicIsStartPageRemovalWarning": "Ce sujet est la page d’accueil d’une instance. Définissez une autre page d’accueil avant de supprimer son fichier source.", "@topicIsStartPageRemovalWarning": { "description": "Warning shown when the topic is used as an instance start page." }, @@ -2694,7 +2700,7 @@ "@topicUsageTocElements": { "description": "Usage group label for TOC elements." }, - "topicUsageStartPages": "Pages de démarrage", + "topicUsageStartPages": "Pages d’accueil", "@topicUsageStartPages": { "description": "Usage group label for instance start pages." }, @@ -3406,5 +3412,12 @@ "nextcloudAttachmentReference": "Référence de la pièce jointe sur le serveur", "nextcloudAddAttachment": "Ajouter une pièce jointe…", "nextcloudDeleteAttachmentWarning": "Supprimer cette pièce jointe rend ses liens dans la note indisponibles. Les données conservées peuvent être récupérées dans l’historique local.", - "nextcloudChooseConflictingAttributes": "Choisissez chaque attribut en conflit." + "nextcloudChooseConflictingAttributes": "Choisissez chaque attribut en conflit.", + "nextcloudUseServerNote": "Utiliser cette note du serveur", + "nextcloudUseServerNoteExplanation": "Lie ce brouillon à la note sélectionnée sur le serveur, puis synchronise les modifications locales conservées. Une requête identique ne confirme pas qui a créé la note.", + "nextcloudExactCandidate": "Correspondance exacte avec la requête ; identité non confirmée.", + "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é." } diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 64bcdb25..b99755a9 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -1,4 +1,6 @@ { + "closeOtherTabs": "अन्य टैब बंद करें", + "@closeOtherTabs": {"description": "Document tab context menu action that closes every other document and comparison tab while retaining this document."}, "tocTopicFromTemplate": "टेम्पलेट से विषय...", "tocSaveAsTemplate": "टेम्पलेट के रूप में सहेजें", "tocTemplateDialog": "टेम्पलेट से विषय बनाएँ", @@ -1877,9 +1879,9 @@ "@errorWritersideTocInvalidMove": { "description": "Detail shown when a Writerside TOC move would create a cycle." }, - "errorWritersideStartTopicDelete": "आरंभिक विषय {topic} को हटाया नहीं जा सकता। पहले कोई दूसरा आरंभ पेज चुनें।", + "errorWritersideStartTopicDelete": "मुखपृष्ठ {topic} को हटाया नहीं जा सकता। पहले कोई दूसरा मुखपृष्ठ चुनें।", "@errorWritersideStartTopicDelete": { - "description": "Detail shown when attempting to delete the start topic of a Writerside instance.", + "description": "Detail shown when attempting to delete the Home Page of a Writerside instance.", "placeholders": { "topic": { "type": "String" @@ -2669,7 +2671,11 @@ } } }, - "topicIsStartPageRemovalWarning": "यह विषय किसी इंस्टेंस के प्रारंभ पृष्ठ के रूप में उपयोग हो रहा है। इसके उपयोगों की समीक्षा करें और आगे बढ़ने से पहले कोई दूसरा प्रारंभ पृष्ठ निर्धारित करें।", + "topicIsHomePageRemovalWarning": "यह विषय इस इंस्टेंस का मुखपृष्ठ है। इसे विषय-सूची से हटाने से पहले कोई दूसरा मुखपृष्ठ चुनें।", + "newHomePage": "नया मुखपृष्ठ", + "chooseHomePage": "मुखपृष्ठ चुनें", + "noHomePageReplacement": "इसका मुखपृष्ठ हटाने से पहले इस इंस्टेंस में कोई दूसरा योग्य विषय जोड़ें या लिंक करें।", + "topicIsStartPageRemovalWarning": "यह विषय किसी इंस्टेंस का मुखपृष्ठ है। इसकी स्रोत फ़ाइल हटाने से पहले कोई दूसरा मुखपृष्ठ निर्धारित करें।", "@topicIsStartPageRemovalWarning": { "description": "Warning shown when the topic is used as an instance start page." }, @@ -2694,7 +2700,7 @@ "@topicUsageTocElements": { "description": "Usage group label for TOC elements." }, - "topicUsageStartPages": "प्रारंभ पृष्ठ", + "topicUsageStartPages": "मुखपृष्ठ", "@topicUsageStartPages": { "description": "Usage group label for instance start pages." }, @@ -3406,5 +3412,12 @@ "nextcloudAttachmentReference": "सर्वर पर संलग्नक का संदर्भ", "nextcloudAddAttachment": "अटैचमेंट जोड़ें…", "nextcloudDeleteAttachmentWarning": "यह अटैचमेंट हटाने पर नोट में इसके लिंक उपलब्ध नहीं रहेंगे। सुरक्षित रखे गए डेटा को स्थानीय इतिहास से पुनर्प्राप्त किया जा सकता है।", - "nextcloudChooseConflictingAttributes": "हर परस्पर विरोधी विशेषता का मान चुनें।" + "nextcloudChooseConflictingAttributes": "हर परस्पर विरोधी विशेषता का मान चुनें।", + "nextcloudUseServerNote": "सर्वर के इस नोट का उपयोग करें", + "nextcloudUseServerNoteExplanation": "इस मसौदे को सर्वर के चुने हुए नोट से जोड़ता है, फिर सुरक्षित स्थानीय बदलाव समन्वयित करता है। अनुरोध का मिलना यह पुष्टि नहीं करता कि नोट किसने बनाया था।", + "nextcloudExactCandidate": "अनुरोध से पूरी तरह मेल; पहचान की पुष्टि नहीं हुई है।", + "nextcloudPossibleCandidate": "संशोधित अनुरोध से संभावित मेल; पहचान की पुष्टि नहीं हुई है।", + "nextcloudSelectCandidate": "समीक्षा के लिए सर्वर का नोट चुनें", + "nextcloudCreateSeparate": "अलग नोट बनाएँ", + "nextcloudCreateSeparateWarning": "पहले अनुरोध ने सर्वर पर पहले ही नोट बना दिया हो सकता है। यह आपके सुरक्षित स्थानीय काम के साथ अलग नोट बनाता है। किसी संभावित प्रतिलिपि को हटाया नहीं जाएगा।" } diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 81b02243..4b25ffd3 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -1,4 +1,6 @@ { + "closeOtherTabs": "Tutup tab lainnya", + "@closeOtherTabs": {"description": "Document tab context menu action that closes every other document and comparison tab while retaining this document."}, "tocTopicFromTemplate": "Topik dari Templat...", "tocSaveAsTemplate": "Simpan sebagai Templat", "tocTemplateDialog": "Buat Topik dari Templat", @@ -1672,9 +1674,13 @@ } } }, - "topicIsStartPageRemovalWarning": "Topik ini digunakan sebagai halaman awal instance. Tinjau penggunaannya dan tetapkan halaman awal lain sebelum melanjutkan.", + "topicIsHomePageRemovalWarning": "Topik ini adalah halaman utama instance ini. Pilih halaman utama lain sebelum menghapusnya dari daftar isi.", + "newHomePage": "Halaman utama baru", + "chooseHomePage": "Pilih halaman utama", + "noHomePageReplacement": "Tambahkan atau tautkan topik lain yang memenuhi syarat ke instance ini sebelum menghapus halaman utamanya.", + "topicIsStartPageRemovalWarning": "Topik ini adalah halaman utama sebuah instance. Tetapkan halaman utama lain sebelum menghapus file sumbernya.", "@topicIsStartPageRemovalWarning": { - "description": "Warning that blocks deletion of a Writerside start-page topic." + "description": "Warning that blocks Safe Delete of a Writerside Home Page." }, "topicUsagesCount": "{count} penggunaan ditemukan.", "@topicUsagesCount": { @@ -1697,9 +1703,9 @@ "@topicUsageTocElements": { "description": "Usage group label for Writerside TOC elements." }, - "topicUsageStartPages": "Halaman awal", + "topicUsageStartPages": "Halaman utama", "@topicUsageStartPages": { - "description": "Usage group label for Writerside instance start pages." + "description": "Usage group label for Writerside instance Home Pages." }, "topicUsageTopicLinks": "Tautan topik", "@topicUsageTopicLinks": { @@ -2508,9 +2514,9 @@ "@errorWritersideTocInvalidMove": { "description": "Detail shown when a Writerside TOC move would create a cycle." }, - "errorWritersideStartTopicDelete": "Topik awal {topic} tidak dapat dihapus. Pilih halaman awal lain terlebih dahulu.", + "errorWritersideStartTopicDelete": "Halaman utama {topic} tidak dapat dihapus. Pilih halaman utama lain terlebih dahulu.", "@errorWritersideStartTopicDelete": { - "description": "Detail shown when attempting to delete the start topic of a Writerside instance.", + "description": "Detail shown when attempting to delete the Home Page of a Writerside instance.", "placeholders": { "topic": { "type": "String" @@ -4945,5 +4951,12 @@ "nextcloudAttachmentReference": "Referensi lampiran di server", "nextcloudAddAttachment": "Tambahkan lampiran…", "nextcloudDeleteAttachmentWarning": "Menghapus lampiran ini membuat tautannya dalam catatan tidak tersedia. Data yang disimpan dapat dipulihkan melalui riwayat lokal.", - "nextcloudChooseConflictingAttributes": "Pilih setiap atribut yang berkonflik." + "nextcloudChooseConflictingAttributes": "Pilih setiap atribut yang berkonflik.", + "nextcloudUseServerNote": "Gunakan catatan server ini", + "nextcloudUseServerNoteExplanation": "Menautkan draf ini ke catatan server yang dipilih, lalu menyinkronkan perubahan lokal yang dipertahankan. Permintaan yang cocok tidak memastikan siapa yang membuat catatan.", + "nextcloudExactCandidate": "Sama persis dengan permintaan; identitas belum dikonfirmasi.", + "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." } diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index daecb24f..74a442bb 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -1,4 +1,6 @@ { + "closeOtherTabs": "Chiudi le altre schede", + "@closeOtherTabs": {"description": "Document tab context menu action that closes every other document and comparison tab while retaining this document."}, "tocTopicFromTemplate": "Argomento da modello...", "tocSaveAsTemplate": "Salva come modello", "tocTemplateDialog": "Crea argomento da modello", @@ -1877,9 +1879,9 @@ "@errorWritersideTocInvalidMove": { "description": "Detail shown when a Writerside TOC move would create a cycle." }, - "errorWritersideStartTopicDelete": "L'argomento iniziale {topic} non può essere eliminato. Scegli prima un'altra pagina iniziale.", + "errorWritersideStartTopicDelete": "La pagina iniziale {topic} non può essere eliminata. Scegli prima un’altra pagina iniziale.", "@errorWritersideStartTopicDelete": { - "description": "Detail shown when attempting to delete the start topic of a Writerside instance.", + "description": "Detail shown when attempting to delete the Home Page of a Writerside instance.", "placeholders": { "topic": { "type": "String" @@ -2669,7 +2671,11 @@ } } }, - "topicIsStartPageRemovalWarning": "Questo argomento è utilizzato come pagina iniziale di un’istanza. Esaminane gli utilizzi e assegna un’altra pagina iniziale prima di continuare.", + "topicIsHomePageRemovalWarning": "Questo argomento è la pagina iniziale di questa istanza. Scegli un’altra pagina iniziale prima di rimuoverlo dal sommario.", + "newHomePage": "Nuova pagina iniziale", + "chooseHomePage": "Scegli una pagina iniziale", + "noHomePageReplacement": "Aggiungi o collega un altro argomento idoneo a questa istanza prima di rimuovere la sua pagina iniziale.", + "topicIsStartPageRemovalWarning": "Questo argomento è la pagina iniziale di un’istanza. Imposta un’altra pagina iniziale prima di eliminare il suo file sorgente.", "@topicIsStartPageRemovalWarning": { "description": "Warning shown when the topic is used as an instance start page." }, @@ -3406,5 +3412,12 @@ "nextcloudAttachmentReference": "Riferimento allegato sul server", "nextcloudAddAttachment": "Aggiungi allegato…", "nextcloudDeleteAttachmentWarning": "Eliminando questo allegato, i suoi collegamenti nella nota non saranno più disponibili. I dati conservati possono essere recuperati dalla cronologia locale.", - "nextcloudChooseConflictingAttributes": "Scegli ogni attributo in conflitto." + "nextcloudChooseConflictingAttributes": "Scegli ogni attributo in conflitto.", + "nextcloudUseServerNote": "Usa questa nota del server", + "nextcloudUseServerNoteExplanation": "Collega questa bozza alla nota selezionata sul server, poi sincronizza le modifiche locali conservate. Una richiesta corrispondente non conferma chi ha creato la nota.", + "nextcloudExactCandidate": "Corrispondenza esatta con la richiesta; identità non confermata.", + "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." } diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 3bb9dc30..b923ea74 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -1,4 +1,6 @@ { + "closeOtherTabs": "他のタブを閉じる", + "@closeOtherTabs": {"description": "Document tab context menu action that closes every other document and comparison tab while retaining this document."}, "tocTopicFromTemplate": "テンプレートからトピック...", "tocSaveAsTemplate": "テンプレートとして保存", "tocTemplateDialog": "テンプレートからトピックを作成", @@ -1672,9 +1674,13 @@ } } }, - "topicIsStartPageRemovalWarning": "このトピックはインスタンスの開始ページとして使用されています。使用箇所を確認し、続行する前に別の開始ページを割り当ててください。", + "topicIsHomePageRemovalWarning": "このトピックはこのインスタンスのホームページです。目次から削除する前に、別のホームページを選択してください。", + "newHomePage": "新しいホームページ", + "chooseHomePage": "ホームページを選択", + "noHomePageReplacement": "ホームページを削除する前に、このインスタンスに別の適切なトピックを追加またはリンクしてください。", + "topicIsStartPageRemovalWarning": "このトピックはインスタンスのホームページです。ソースファイルを削除する前に、別のホームページを設定してください。", "@topicIsStartPageRemovalWarning": { - "description": "Warning that blocks deletion of a Writerside start-page topic." + "description": "Warning that blocks Safe Delete of a Writerside Home Page." }, "topicUsagesCount": "{count} 件の使用箇所が見つかりました。", "@topicUsagesCount": { @@ -1697,9 +1703,9 @@ "@topicUsageTocElements": { "description": "Usage group label for Writerside TOC elements." }, - "topicUsageStartPages": "開始ページ", + "topicUsageStartPages": "ホームページ", "@topicUsageStartPages": { - "description": "Usage group label for Writerside instance start pages." + "description": "Usage group label for Writerside instance Home Pages." }, "topicUsageTopicLinks": "トピックリンク", "@topicUsageTopicLinks": { @@ -2508,9 +2514,9 @@ "@errorWritersideTocInvalidMove": { "description": "Detail shown when a Writerside TOC move would create a cycle." }, - "errorWritersideStartTopicDelete": "開始トピック {topic} は削除できません。先に別の開始ページを選択してください。", + "errorWritersideStartTopicDelete": "ホームページ {topic} は削除できません。先に別のホームページを選択してください。", "@errorWritersideStartTopicDelete": { - "description": "Detail shown when attempting to delete the start topic of a Writerside instance.", + "description": "Detail shown when attempting to delete the Home Page of a Writerside instance.", "placeholders": { "topic": { "type": "String" @@ -4945,5 +4951,12 @@ "nextcloudAttachmentReference": "サーバー上の添付ファイル参照", "nextcloudAddAttachment": "添付ファイルを追加…", "nextcloudDeleteAttachmentWarning": "この添付ファイルを削除すると、ノート内のリンクは利用できなくなります。保持されたデータはローカル履歴から復元できます。", - "nextcloudChooseConflictingAttributes": "競合する各属性の値を選択してください。" + "nextcloudChooseConflictingAttributes": "競合する各属性の値を選択してください。", + "nextcloudUseServerNote": "このサーバーノートを使用", + "nextcloudUseServerNoteExplanation": "この下書きを選択したサーバーノートにリンクし、保持したローカル変更を同期します。リクエストの一致だけでは作成者を確認できません。", + "nextcloudExactCandidate": "リクエストと完全一致。識別情報は未確認です。", + "nextcloudPossibleCandidate": "正規化されたリクエストと一致する可能性あり。識別情報は未確認です。", + "nextcloudSelectCandidate": "確認するサーバーノートを選択", + "nextcloudCreateSeparate": "別のノートを作成", + "nextcloudCreateSeparateWarning": "最初のリクエストですでにサーバーノートが作成された可能性があります。保持したローカル作業から別のノートを作成します。重複の疑いがあるノートは削除されません。" } diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index cfaf321d..49ab8b8c 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -1,4 +1,6 @@ { + "closeOtherTabs": "다른 탭 닫기", + "@closeOtherTabs": {"description": "Document tab context menu action that closes every other document and comparison tab while retaining this document."}, "tocTopicFromTemplate": "템플릿에서 토픽...", "tocSaveAsTemplate": "템플릿으로 저장", "tocTemplateDialog": "템플릿에서 토픽 만들기", @@ -1672,9 +1674,13 @@ } } }, - "topicIsStartPageRemovalWarning": "이 토픽은 인스턴스 시작 페이지로 사용됩니다. 계속하기 전에 사용처를 검토하고 다른 시작 페이지를 지정하세요.", + "topicIsHomePageRemovalWarning": "이 토픽은 이 인스턴스의 홈 페이지입니다. 목차에서 제거하기 전에 다른 홈 페이지를 선택하세요.", + "newHomePage": "새 홈 페이지", + "chooseHomePage": "홈 페이지 선택", + "noHomePageReplacement": "홈 페이지를 제거하기 전에 이 인스턴스에 다른 적합한 토픽을 추가하거나 연결하세요.", + "topicIsStartPageRemovalWarning": "이 토픽은 인스턴스의 홈 페이지입니다. 소스 파일을 삭제하기 전에 다른 홈 페이지를 지정하세요.", "@topicIsStartPageRemovalWarning": { - "description": "Warning that blocks deletion of a Writerside start-page topic." + "description": "Warning that blocks Safe Delete of a Writerside Home Page." }, "topicUsagesCount": "사용처 {count}개를 찾았습니다.", "@topicUsagesCount": { @@ -1697,9 +1703,9 @@ "@topicUsageTocElements": { "description": "Usage group label for Writerside TOC elements." }, - "topicUsageStartPages": "시작 페이지", + "topicUsageStartPages": "홈 페이지", "@topicUsageStartPages": { - "description": "Usage group label for Writerside instance start pages." + "description": "Usage group label for Writerside instance Home Pages." }, "topicUsageTopicLinks": "토픽 링크", "@topicUsageTopicLinks": { @@ -2508,9 +2514,9 @@ "@errorWritersideTocInvalidMove": { "description": "Detail shown when a Writerside TOC move would create a cycle." }, - "errorWritersideStartTopicDelete": "시작 토픽 {topic}을(를) 삭제할 수 없습니다. 먼저 다른 시작 페이지를 선택하세요.", + "errorWritersideStartTopicDelete": "홈 페이지 {topic}을(를) 삭제할 수 없습니다. 먼저 다른 홈 페이지를 선택하세요.", "@errorWritersideStartTopicDelete": { - "description": "Detail shown when attempting to delete the start topic of a Writerside instance.", + "description": "Detail shown when attempting to delete the Home Page of a Writerside instance.", "placeholders": { "topic": { "type": "String" @@ -4945,5 +4951,12 @@ "nextcloudAttachmentReference": "서버의 첨부 파일 참조", "nextcloudAddAttachment": "첨부 파일 추가…", "nextcloudDeleteAttachmentWarning": "이 첨부 파일을 삭제하면 노트 안의 해당 링크를 사용할 수 없습니다. 보관된 데이터는 로컬 기록에서 복구할 수 있습니다.", - "nextcloudChooseConflictingAttributes": "충돌하는 각 속성의 값을 선택하세요." + "nextcloudChooseConflictingAttributes": "충돌하는 각 속성의 값을 선택하세요.", + "nextcloudUseServerNote": "이 서버 노트 사용", + "nextcloudUseServerNoteExplanation": "이 초안을 선택한 서버 노트에 연결한 다음 보존된 로컬 변경 사항을 동기화합니다. 요청이 일치해도 누가 노트를 만들었는지는 확인되지 않습니다.", + "nextcloudExactCandidate": "요청과 정확히 일치합니다. 동일한 노트인지 확인되지 않았습니다.", + "nextcloudPossibleCandidate": "정리된 요청과 일치할 수 있습니다. 동일한 노트인지 확인되지 않았습니다.", + "nextcloudSelectCandidate": "검토할 서버 노트 선택", + "nextcloudCreateSeparate": "별도 노트 만들기", + "nextcloudCreateSeparateWarning": "첫 요청으로 서버 노트가 이미 만들어졌을 수 있습니다. 보존된 로컬 작업으로 별도 노트를 만듭니다. 중복으로 의심되는 노트는 삭제하지 않습니다." } diff --git a/lib/l10n/app_nb.arb b/lib/l10n/app_nb.arb index 5bfc99b2..218d41f3 100644 --- a/lib/l10n/app_nb.arb +++ b/lib/l10n/app_nb.arb @@ -1,4 +1,6 @@ { + "closeOtherTabs": "Lukk andre faner", + "@closeOtherTabs": {"description": "Document tab context menu action that closes every other document and comparison tab while retaining this document."}, "tocTopicFromTemplate": "Emne fra mal...", "tocSaveAsTemplate": "Lagre som mal", "tocTemplateDialog": "Opprett emne fra mal", @@ -1877,9 +1879,9 @@ "@errorWritersideTocInvalidMove": { "description": "Detail shown when a Writerside TOC move would create a cycle." }, - "errorWritersideStartTopicDelete": "Startemnet {topic} kan ikke slettes. Velg en annen startside først.", + "errorWritersideStartTopicDelete": "Startsiden {topic} kan ikke slettes. Velg en annen startside først.", "@errorWritersideStartTopicDelete": { - "description": "Detail shown when attempting to delete the start topic of a Writerside instance.", + "description": "Detail shown when attempting to delete the Home Page of a Writerside instance.", "placeholders": { "topic": { "type": "String" @@ -2669,7 +2671,11 @@ } } }, - "topicIsStartPageRemovalWarning": "Dette emnet brukes som startside for en instans. Se gjennom bruken, og angi en annen startside før du fortsetter.", + "topicIsHomePageRemovalWarning": "Dette emnet er startsiden for denne instansen. Velg en annen startside før du fjerner det fra innholdsfortegnelsen.", + "newHomePage": "Ny startside", + "chooseHomePage": "Velg en startside", + "noHomePageReplacement": "Legg til eller koble et annet egnet emne til denne instansen før du fjerner startsiden.", + "topicIsStartPageRemovalWarning": "Dette emnet er startsiden for en instans. Angi en annen startside før du sletter kildefilen.", "@topicIsStartPageRemovalWarning": { "description": "Warning shown when the topic is used as an instance start page." }, @@ -3406,5 +3412,12 @@ "nextcloudAttachmentReference": "Vedleggsreferanse på serveren", "nextcloudAddAttachment": "Legg til vedlegg…", "nextcloudDeleteAttachmentWarning": "Når du sletter dette vedlegget, blir lenkene til det i notatet utilgjengelige. Lagrede data kan gjenopprettes fra lokal historikk.", - "nextcloudChooseConflictingAttributes": "Velg hver motstridende egenskap." + "nextcloudChooseConflictingAttributes": "Velg hver motstridende egenskap.", + "nextcloudUseServerNote": "Bruk dette servernotatet", + "nextcloudUseServerNoteExplanation": "Knytter dette utkastet til det valgte servernotatet og synkroniserer deretter beholdte lokale endringer. En samsvarende forespørsel bekrefter ikke hvem som opprettet notatet.", + "nextcloudExactCandidate": "Nøyaktig samsvar med forespørselen; identiteten er ubekreftet.", + "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." } diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index 6e953260..308d7c3b 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -1,4 +1,6 @@ { + "closeOtherTabs": "Andere tabbladen sluiten", + "@closeOtherTabs": {"description": "Document tab context menu action that closes every other document and comparison tab while retaining this document."}, "tocTopicFromTemplate": "Onderwerp uit sjabloon...", "tocSaveAsTemplate": "Opslaan als sjabloon", "tocTemplateDialog": "Onderwerp uit sjabloon maken", @@ -1672,9 +1674,13 @@ } } }, - "topicIsStartPageRemovalWarning": "Dit onderwerp wordt gebruikt als startpagina van een instantie. Controleer het gebruik ervan en wijs een andere startpagina toe voordat u verdergaat.", + "topicIsHomePageRemovalWarning": "Dit onderwerp is de startpagina van deze instantie. Kies een andere startpagina voordat u het uit de inhoudsopgave verwijdert.", + "newHomePage": "Nieuwe startpagina", + "chooseHomePage": "Startpagina kiezen", + "noHomePageReplacement": "Voeg een ander geschikt onderwerp toe aan deze instantie of koppel het voordat u de startpagina verwijdert.", + "topicIsStartPageRemovalWarning": "Dit onderwerp is de startpagina van een instantie. Stel een andere startpagina in voordat u het bronbestand verwijdert.", "@topicIsStartPageRemovalWarning": { - "description": "Warning that blocks deletion of a Writerside start-page topic." + "description": "Warning that blocks Safe Delete of a Writerside Home Page." }, "topicUsagesCount": "{count} gebruikslocaties gevonden.", "@topicUsagesCount": { @@ -1697,9 +1703,9 @@ "@topicUsageTocElements": { "description": "Usage group label for Writerside TOC elements." }, - "topicUsageStartPages": "Startpagina's", + "topicUsageStartPages": "Startpagina’s", "@topicUsageStartPages": { - "description": "Usage group label for Writerside instance start pages." + "description": "Usage group label for Writerside instance Home Pages." }, "topicUsageTopicLinks": "Onderwerplinks", "@topicUsageTopicLinks": { @@ -2508,9 +2514,9 @@ "@errorWritersideTocInvalidMove": { "description": "Detail shown when a Writerside TOC move would create a cycle." }, - "errorWritersideStartTopicDelete": "Het startonderwerp {topic} kan niet worden verwijderd. Kies eerst een andere startpagina.", + "errorWritersideStartTopicDelete": "De startpagina {topic} kan niet worden verwijderd. Kies eerst een andere startpagina.", "@errorWritersideStartTopicDelete": { - "description": "Detail shown when attempting to delete the start topic of a Writerside instance.", + "description": "Detail shown when attempting to delete the Home Page of a Writerside instance.", "placeholders": { "topic": { "type": "String" @@ -4945,5 +4951,12 @@ "nextcloudAttachmentReference": "Bijlageverwijzing op de server", "nextcloudAddAttachment": "Bijlage toevoegen…", "nextcloudDeleteAttachmentWarning": "Als u deze bijlage verwijdert, zijn de links ernaar in de notitie niet meer beschikbaar. Bewaarde gegevens kunnen via de lokale geschiedenis worden hersteld.", - "nextcloudChooseConflictingAttributes": "Kies voor elk conflicterend kenmerk een waarde." + "nextcloudChooseConflictingAttributes": "Kies voor elk conflicterend kenmerk een waarde.", + "nextcloudUseServerNote": "Deze servernotitie gebruiken", + "nextcloudUseServerNoteExplanation": "Koppelt dit concept aan de geselecteerde servernotitie en synchroniseert daarna de behouden lokale wijzigingen. Een overeenkomend verzoek bevestigt niet wie de notitie heeft gemaakt.", + "nextcloudExactCandidate": "Exacte overeenkomst met het verzoek; identiteit onbevestigd.", + "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." } diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index 24a14d09..f65627db 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -1,4 +1,6 @@ { + "closeOtherTabs": "Zamknij pozostałe karty", + "@closeOtherTabs": {"description": "Document tab context menu action that closes every other document and comparison tab while retaining this document."}, "tocTopicFromTemplate": "Temat z szablonu...", "tocSaveAsTemplate": "Zapisz jako szablon", "tocTemplateDialog": "Utwórz temat z szablonu", @@ -1877,9 +1879,9 @@ "@errorWritersideTocInvalidMove": { "description": "Detail shown when a Writerside TOC move would create a cycle." }, - "errorWritersideStartTopicDelete": "Nie można usunąć tematu początkowego {topic}. Najpierw wybierz inną stronę początkową.", + "errorWritersideStartTopicDelete": "Nie można usunąć strony głównej {topic}. Najpierw wybierz inną stronę główną.", "@errorWritersideStartTopicDelete": { - "description": "Detail shown when attempting to delete the start topic of a Writerside instance.", + "description": "Detail shown when attempting to delete the Home Page of a Writerside instance.", "placeholders": { "topic": { "type": "String" @@ -2695,7 +2697,11 @@ } } }, - "topicIsStartPageRemovalWarning": "Ten temat jest używany jako strona początkowa instancji. Przejrzyj jego użycia i przypisz inną stronę początkową przed kontynuowaniem.", + "topicIsHomePageRemovalWarning": "Ten temat jest stroną główną tej instancji. Wybierz inną stronę główną przed usunięciem go ze spisu treści.", + "newHomePage": "Nowa strona główna", + "chooseHomePage": "Wybierz stronę główną", + "noHomePageReplacement": "Dodaj lub połącz inny odpowiedni temat z tą instancją przed usunięciem jej strony głównej.", + "topicIsStartPageRemovalWarning": "Ten temat jest stroną główną instancji. Ustaw inną stronę główną przed usunięciem jego pliku źródłowego.", "@topicIsStartPageRemovalWarning": { "description": "Warning shown when the topic is used as an instance start page." }, @@ -2720,7 +2726,7 @@ "@topicUsageTocElements": { "description": "Usage group label for TOC elements." }, - "topicUsageStartPages": "Strony początkowe", + "topicUsageStartPages": "Strony główne", "@topicUsageStartPages": { "description": "Usage group label for instance start pages." }, @@ -3432,5 +3438,12 @@ "nextcloudAttachmentReference": "Odnośnik do załącznika na serwerze", "nextcloudAddAttachment": "Dodaj załącznik…", "nextcloudDeleteAttachmentWarning": "Usunięcie tego załącznika spowoduje, że jego odnośniki w notatce będą niedostępne. Zachowane dane można odzyskać z lokalnej historii.", - "nextcloudChooseConflictingAttributes": "Wybierz wartość każdego sprzecznego atrybutu." + "nextcloudChooseConflictingAttributes": "Wybierz wartość każdego sprzecznego atrybutu.", + "nextcloudUseServerNote": "Użyj tej notatki z serwera", + "nextcloudUseServerNoteExplanation": "Łączy ten szkic z wybraną notatką na serwerze, a następnie synchronizuje zachowane zmiany lokalne. Zgodne żądanie nie potwierdza, kto utworzył notatkę.", + "nextcloudExactCandidate": "Dokładna zgodność z żądaniem; tożsamość niepotwierdzona.", + "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." } diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index bd8bca48..448b4cee 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -1,4 +1,6 @@ { + "closeOtherTabs": "Fechar os outros separadores", + "@closeOtherTabs": {"description": "Document tab context menu action that closes every other document and comparison tab while retaining this document."}, "tocTopicFromTemplate": "Tópico a partir de modelo...", "tocSaveAsTemplate": "Guardar como modelo", "tocTemplateDialog": "Criar tópico a partir de modelo", @@ -1877,9 +1879,9 @@ "@errorWritersideTocInvalidMove": { "description": "Detail shown when a Writerside TOC move would create a cycle." }, - "errorWritersideStartTopicDelete": "O tópico inicial {topic} não pode ser eliminado. Escolha primeiro outra página inicial.", + "errorWritersideStartTopicDelete": "Não é possível eliminar a página inicial {topic}. Escolha primeiro outra página inicial.", "@errorWritersideStartTopicDelete": { - "description": "Detail shown when attempting to delete the start topic of a Writerside instance.", + "description": "Detail shown when attempting to delete the Home Page of a Writerside instance.", "placeholders": { "topic": { "type": "String" @@ -2669,7 +2671,11 @@ } } }, - "topicIsStartPageRemovalWarning": "Este tópico é usado como página inicial de uma instância. Reveja os usos dele e atribua outra página inicial antes de continuar.", + "topicIsHomePageRemovalWarning": "Este tópico é a página inicial desta instância. Escolha outra página inicial antes de o remover do índice.", + "newHomePage": "Nova página inicial", + "chooseHomePage": "Escolher página inicial", + "noHomePageReplacement": "Adicione ou associe outro tópico elegível a esta instância antes de remover a sua página inicial.", + "topicIsStartPageRemovalWarning": "Este tópico é a página inicial de uma instância. Defina outra página inicial antes de eliminar o seu ficheiro de origem.", "@topicIsStartPageRemovalWarning": { "description": "Warning shown when the topic is used as an instance start page." }, @@ -3406,5 +3412,12 @@ "nextcloudAttachmentReference": "Referência do anexo no servidor", "nextcloudAddAttachment": "Adicionar anexo…", "nextcloudDeleteAttachmentWarning": "Ao eliminar este anexo, as suas ligações na nota deixam de estar disponíveis. Os dados conservados podem ser recuperados através do histórico local.", - "nextcloudChooseConflictingAttributes": "Escolha cada atributo em conflito." + "nextcloudChooseConflictingAttributes": "Escolha cada atributo em conflito.", + "nextcloudUseServerNote": "Usar esta nota do servidor", + "nextcloudUseServerNoteExplanation": "Associa este rascunho à nota selecionada no servidor e depois sincroniza as alterações locais preservadas. Um pedido correspondente não confirma quem criou a nota.", + "nextcloudExactCandidate": "Correspondência exata com o pedido; identidade não confirmada.", + "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." } diff --git a/lib/l10n/app_pt_BR.arb b/lib/l10n/app_pt_BR.arb index fe2e86ad..5f6cdadf 100644 --- a/lib/l10n/app_pt_BR.arb +++ b/lib/l10n/app_pt_BR.arb @@ -1,4 +1,6 @@ { + "closeOtherTabs": "Fechar as outras abas", + "@closeOtherTabs": {"description": "Document tab context menu action that closes every other document and comparison tab while retaining this document."}, "tocTopicFromTemplate": "Tópico a partir de modelo...", "tocSaveAsTemplate": "Salvar como modelo", "tocTemplateDialog": "Criar tópico a partir de modelo", @@ -1877,9 +1879,9 @@ "@errorWritersideTocInvalidMove": { "description": "Detail shown when a Writerside TOC move would create a cycle." }, - "errorWritersideStartTopicDelete": "O tópico inicial {topic} não pode ser excluído. Escolha primeiro outra página inicial.", + "errorWritersideStartTopicDelete": "Não é possível excluir a página inicial {topic}. Escolha outra página inicial primeiro.", "@errorWritersideStartTopicDelete": { - "description": "Detail shown when attempting to delete the start topic of a Writerside instance.", + "description": "Detail shown when attempting to delete the Home Page of a Writerside instance.", "placeholders": { "topic": { "type": "String" @@ -2669,7 +2671,11 @@ } } }, - "topicIsStartPageRemovalWarning": "Este tópico é usado como página inicial de uma instância. Revise os usos dele e atribua outra página inicial antes de continuar.", + "topicIsHomePageRemovalWarning": "Este tópico é a página inicial desta instância. Escolha outra página inicial antes de removê-lo do sumário.", + "newHomePage": "Nova página inicial", + "chooseHomePage": "Escolher página inicial", + "noHomePageReplacement": "Adicione ou vincule outro tópico elegível a esta instância antes de remover sua página inicial.", + "topicIsStartPageRemovalWarning": "Este tópico é a página inicial de uma instância. Defina outra página inicial antes de excluir seu arquivo de origem.", "@topicIsStartPageRemovalWarning": { "description": "Warning shown when the topic is used as an instance start page." }, @@ -3406,5 +3412,12 @@ "nextcloudAttachmentReference": "Referência do anexo no servidor", "nextcloudAddAttachment": "Adicionar anexo…", "nextcloudDeleteAttachmentWarning": "Excluir este anexo torna seus links na nota indisponíveis. Os dados preservados podem ser recuperados pelo histórico local.", - "nextcloudChooseConflictingAttributes": "Escolha cada atributo em conflito." + "nextcloudChooseConflictingAttributes": "Escolha cada atributo em conflito.", + "nextcloudUseServerNote": "Usar esta nota do servidor", + "nextcloudUseServerNoteExplanation": "Vincula este rascunho à nota selecionada no servidor e depois sincroniza as alterações locais preservadas. Uma solicitação correspondente não confirma quem criou a nota.", + "nextcloudExactCandidate": "Correspondência exata com a solicitação; identidade não confirmada.", + "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." } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 36d8bc28..e2553671 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -1,4 +1,6 @@ { + "closeOtherTabs": "Закрыть другие вкладки", + "@closeOtherTabs": {"description": "Document tab context menu action that closes every other document and comparison tab while retaining this document."}, "tocTopicFromTemplate": "Тема из шаблона...", "tocSaveAsTemplate": "Сохранить как шаблон", "tocTemplateDialog": "Создать тему из шаблона", @@ -1877,9 +1879,9 @@ "@errorWritersideTocInvalidMove": { "description": "Detail shown when a Writerside TOC move would create a cycle." }, - "errorWritersideStartTopicDelete": "Стартовую тему {topic} нельзя удалить. Сначала выберите другую стартовую страницу.", + "errorWritersideStartTopicDelete": "Домашнюю страницу {topic} нельзя удалить. Сначала выберите другую домашнюю страницу.", "@errorWritersideStartTopicDelete": { - "description": "Detail shown when attempting to delete the start topic of a Writerside instance.", + "description": "Detail shown when attempting to delete the Home Page of a Writerside instance.", "placeholders": { "topic": { "type": "String" @@ -2695,7 +2697,11 @@ } } }, - "topicIsStartPageRemovalWarning": "Эта тема используется как стартовая страница экземпляра. Просмотрите её использования и назначьте другую стартовую страницу, прежде чем продолжить.", + "topicIsHomePageRemovalWarning": "Эта тема является домашней страницей этого экземпляра. Выберите другую домашнюю страницу перед её удалением из оглавления.", + "newHomePage": "Новая домашняя страница", + "chooseHomePage": "Выбрать домашнюю страницу", + "noHomePageReplacement": "Добавьте или привяжите к этому экземпляру другую подходящую тему перед удалением его домашней страницы.", + "topicIsStartPageRemovalWarning": "Эта тема является домашней страницей экземпляра. Назначьте другую домашнюю страницу перед удалением её исходного файла.", "@topicIsStartPageRemovalWarning": { "description": "Warning shown when the topic is used as an instance start page." }, @@ -2720,7 +2726,7 @@ "@topicUsageTocElements": { "description": "Usage group label for TOC elements." }, - "topicUsageStartPages": "Стартовые страницы", + "topicUsageStartPages": "Домашние страницы", "@topicUsageStartPages": { "description": "Usage group label for instance start pages." }, @@ -3432,5 +3438,12 @@ "nextcloudAttachmentReference": "Ссылка на вложение на сервере", "nextcloudAddAttachment": "Добавить вложение…", "nextcloudDeleteAttachmentWarning": "После удаления этого вложения ссылки на него в заметке станут недоступны. Сохранённые данные можно восстановить через локальную историю.", - "nextcloudChooseConflictingAttributes": "Выберите значение каждого конфликтующего атрибута." + "nextcloudChooseConflictingAttributes": "Выберите значение каждого конфликтующего атрибута.", + "nextcloudUseServerNote": "Использовать эту заметку на сервере", + "nextcloudUseServerNoteExplanation": "Связывает этот черновик с выбранной заметкой на сервере, затем синхронизирует сохранённые локальные изменения. Совпадение запроса не подтверждает, кто создал заметку.", + "nextcloudExactCandidate": "Точное совпадение с запросом; идентичность не подтверждена.", + "nextcloudPossibleCandidate": "Возможное совпадение после нормализации; идентичность не подтверждена.", + "nextcloudSelectCandidate": "Выберите заметку на сервере для проверки", + "nextcloudCreateSeparate": "Создать отдельную заметку", + "nextcloudCreateSeparateWarning": "Первый запрос мог уже создать заметку на сервере. Будет создана отдельная заметка с сохранённой локальной работой. Предполагаемые дубликаты не будут удалены." } diff --git a/lib/l10n/app_tr.arb b/lib/l10n/app_tr.arb index 0e4f40ac..8c2ca4d1 100644 --- a/lib/l10n/app_tr.arb +++ b/lib/l10n/app_tr.arb @@ -1,4 +1,6 @@ { + "closeOtherTabs": "Diğer sekmeleri kapat", + "@closeOtherTabs": {"description": "Document tab context menu action that closes every other document and comparison tab while retaining this document."}, "tocTopicFromTemplate": "Şablondan Konu...", "tocSaveAsTemplate": "Şablon Olarak Kaydet", "tocTemplateDialog": "Şablondan Konu Oluştur", @@ -1672,9 +1674,13 @@ } } }, - "topicIsStartPageRemovalWarning": "Bu konu bir Writerside örneğinin başlangıç sayfası olarak kullanılıyor. Devam etmeden önce kullanımlarını inceleyin ve başka bir başlangıç sayfası atayın.", + "topicIsHomePageRemovalWarning": "Bu konu bu örneğin ana sayfasıdır. İçindekilerden kaldırmadan önce başka bir ana sayfa seçin.", + "newHomePage": "Yeni ana sayfa", + "chooseHomePage": "Ana sayfa seçin", + "noHomePageReplacement": "Ana sayfasını kaldırmadan önce bu örneğe başka bir uygun konu ekleyin veya bağlayın.", + "topicIsStartPageRemovalWarning": "Bu konu bir örneğin ana sayfasıdır. Kaynak dosyasını silmeden önce başka bir ana sayfa atayın.", "@topicIsStartPageRemovalWarning": { - "description": "Warning that blocks deletion of a Writerside start-page topic." + "description": "Warning that blocks Safe Delete of a Writerside Home Page." }, "topicUsagesCount": "{count} kullanım bulundu.", "@topicUsagesCount": { @@ -1697,9 +1703,9 @@ "@topicUsageTocElements": { "description": "Usage group label for Writerside TOC elements." }, - "topicUsageStartPages": "Başlangıç sayfaları", + "topicUsageStartPages": "Ana sayfalar", "@topicUsageStartPages": { - "description": "Usage group label for Writerside instance start pages." + "description": "Usage group label for Writerside instance Home Pages." }, "topicUsageTopicLinks": "Konu bağlantıları", "@topicUsageTopicLinks": { @@ -2508,9 +2514,9 @@ "@errorWritersideTocInvalidMove": { "description": "Detail shown when a Writerside TOC move would create a cycle." }, - "errorWritersideStartTopicDelete": "{topic} başlangıç konusu silinemez. Önce başka bir başlangıç sayfası seçin.", + "errorWritersideStartTopicDelete": "Ana sayfa {topic} silinemez. Önce başka bir ana sayfa seçin.", "@errorWritersideStartTopicDelete": { - "description": "Detail shown when attempting to delete the start topic of a Writerside instance.", + "description": "Detail shown when attempting to delete the Home Page of a Writerside instance.", "placeholders": { "topic": { "type": "String" @@ -4945,5 +4951,12 @@ "nextcloudAttachmentReference": "Sunucudaki ek başvurusu", "nextcloudAddAttachment": "Ek ekle…", "nextcloudDeleteAttachmentWarning": "Bu eki silmek, nottaki bağlantılarını kullanılamaz hale getirir. Saklanan veriler yerel geçmişten kurtarılabilir.", - "nextcloudChooseConflictingAttributes": "Çakışan her öznitelik için bir değer seçin." + "nextcloudChooseConflictingAttributes": "Çakışan her öznitelik için bir değer seçin.", + "nextcloudUseServerNote": "Bu sunucu notunu kullan", + "nextcloudUseServerNoteExplanation": "Bu taslağı seçilen sunucu notuna bağlar, ardından korunan yerel değişiklikleri eşitler. Eşleşen bir istek, notu kimin oluşturduğunu doğrulamaz.", + "nextcloudExactCandidate": "İstekle tam eşleşme; kimlik doğrulanmadı.", + "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." } diff --git a/lib/l10n/app_uk.arb b/lib/l10n/app_uk.arb index d9138e6b..a3f6a69d 100644 --- a/lib/l10n/app_uk.arb +++ b/lib/l10n/app_uk.arb @@ -1,4 +1,6 @@ { + "closeOtherTabs": "Закрити інші вкладки", + "@closeOtherTabs": {"description": "Document tab context menu action that closes every other document and comparison tab while retaining this document."}, "tocTopicFromTemplate": "Тема з шаблону...", "tocSaveAsTemplate": "Зберегти як шаблон", "tocTemplateDialog": "Створити тему з шаблону", @@ -1877,9 +1879,9 @@ "@errorWritersideTocInvalidMove": { "description": "Detail shown when a Writerside TOC move would create a cycle." }, - "errorWritersideStartTopicDelete": "Початкову тему {topic} не можна видалити. Спочатку виберіть іншу початкову сторінку.", + "errorWritersideStartTopicDelete": "Домашню сторінку {topic} не можна видалити. Спочатку виберіть іншу домашню сторінку.", "@errorWritersideStartTopicDelete": { - "description": "Detail shown when attempting to delete the start topic of a Writerside instance.", + "description": "Detail shown when attempting to delete the Home Page of a Writerside instance.", "placeholders": { "topic": { "type": "String" @@ -2695,7 +2697,11 @@ } } }, - "topicIsStartPageRemovalWarning": "Ця тема використовується як початкова сторінка екземпляра. Перегляньте її використання та призначте іншу початкову сторінку, перш ніж продовжити.", + "topicIsHomePageRemovalWarning": "Ця тема є домашньою сторінкою цього екземпляра. Виберіть іншу домашню сторінку, перш ніж видалити її зі змісту.", + "newHomePage": "Нова домашня сторінка", + "chooseHomePage": "Вибрати домашню сторінку", + "noHomePageReplacement": "Додайте або прив’яжіть іншу придатну тему до цього екземпляра перед видаленням його домашньої сторінки.", + "topicIsStartPageRemovalWarning": "Ця тема є домашньою сторінкою екземпляра. Призначте іншу домашню сторінку перед видаленням її вихідного файлу.", "@topicIsStartPageRemovalWarning": { "description": "Warning shown when the topic is used as an instance start page." }, @@ -2720,7 +2726,7 @@ "@topicUsageTocElements": { "description": "Usage group label for TOC elements." }, - "topicUsageStartPages": "Початкові сторінки", + "topicUsageStartPages": "Домашні сторінки", "@topicUsageStartPages": { "description": "Usage group label for instance start pages." }, @@ -3432,5 +3438,12 @@ "nextcloudAttachmentReference": "Посилання на вкладення на сервері", "nextcloudAddAttachment": "Додати вкладення…", "nextcloudDeleteAttachmentWarning": "Після видалення цього вкладення посилання на нього в нотатці стануть недоступними. Збережені дані можна відновити з локальної історії.", - "nextcloudChooseConflictingAttributes": "Виберіть значення кожного конфліктного атрибута." + "nextcloudChooseConflictingAttributes": "Виберіть значення кожного конфліктного атрибута.", + "nextcloudUseServerNote": "Використати цю нотатку на сервері", + "nextcloudUseServerNoteExplanation": "Пов’язує цю чернетку з вибраною нотаткою на сервері, а потім синхронізує збережені локальні зміни. Збіг запиту не підтверджує, хто створив нотатку.", + "nextcloudExactCandidate": "Точний збіг із запитом; тотожність не підтверджено.", + "nextcloudPossibleCandidate": "Можливий збіг після нормалізації; тотожність не підтверджено.", + "nextcloudSelectCandidate": "Виберіть нотатку на сервері для перегляду", + "nextcloudCreateSeparate": "Створити окрему нотатку", + "nextcloudCreateSeparateWarning": "Перший запит міг уже створити нотатку на сервері. Буде створено окрему нотатку зі збереженою локальною роботою. Ймовірні дублікати не буде видалено." } diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index a335918b..27e538ac 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -1,4 +1,6 @@ { + "closeOtherTabs": "Đóng các thẻ khác", + "@closeOtherTabs": {"description": "Document tab context menu action that closes every other document and comparison tab while retaining this document."}, "tocTopicFromTemplate": "Chủ đề từ mẫu...", "tocSaveAsTemplate": "Lưu thành mẫu", "tocTemplateDialog": "Tạo chủ đề từ mẫu", @@ -1672,9 +1674,13 @@ } } }, - "topicIsStartPageRemovalWarning": "Chủ đề này đang được dùng làm trang bắt đầu của một thực thể. Hãy xem lại cách sử dụng và chỉ định trang bắt đầu khác trước khi tiếp tục.", + "topicIsHomePageRemovalWarning": "Chủ đề này là trang chủ của thực thể này. Chọn trang chủ khác trước khi xóa chủ đề khỏi mục lục.", + "newHomePage": "Trang chủ mới", + "chooseHomePage": "Chọn trang chủ", + "noHomePageReplacement": "Thêm hoặc liên kết một chủ đề hợp lệ khác vào thực thể này trước khi xóa trang chủ của nó.", + "topicIsStartPageRemovalWarning": "Chủ đề này là trang chủ của một thực thể. Chỉ định trang chủ khác trước khi xóa tệp nguồn của chủ đề.", "@topicIsStartPageRemovalWarning": { - "description": "Warning that blocks deletion of a Writerside start-page topic." + "description": "Warning that blocks Safe Delete of a Writerside Home Page." }, "topicUsagesCount": "Tìm thấy {count} lượt sử dụng.", "@topicUsagesCount": { @@ -1697,9 +1703,9 @@ "@topicUsageTocElements": { "description": "Usage group label for Writerside TOC elements." }, - "topicUsageStartPages": "Trang bắt đầu", + "topicUsageStartPages": "Các trang chủ", "@topicUsageStartPages": { - "description": "Usage group label for Writerside instance start pages." + "description": "Usage group label for Writerside instance Home Pages." }, "topicUsageTopicLinks": "Liên kết chủ đề", "@topicUsageTopicLinks": { @@ -2508,9 +2514,9 @@ "@errorWritersideTocInvalidMove": { "description": "Detail shown when a Writerside TOC move would create a cycle." }, - "errorWritersideStartTopicDelete": "Không thể xóa chủ đề bắt đầu {topic}. Hãy chọn một trang bắt đầu khác trước.", + "errorWritersideStartTopicDelete": "Không thể xóa trang chủ {topic}. Hãy chọn trang chủ khác trước.", "@errorWritersideStartTopicDelete": { - "description": "Detail shown when attempting to delete the start topic of a Writerside instance.", + "description": "Detail shown when attempting to delete the Home Page of a Writerside instance.", "placeholders": { "topic": { "type": "String" @@ -4945,5 +4951,12 @@ "nextcloudAttachmentReference": "Tham chiếu tệp đính kèm trên máy chủ", "nextcloudAddAttachment": "Thêm tệp đính kèm…", "nextcloudDeleteAttachmentWarning": "Xóa tệp đính kèm này sẽ khiến các liên kết đến tệp trong ghi chú không còn khả dụng. Dữ liệu được giữ lại có thể khôi phục qua lịch sử cục bộ.", - "nextcloudChooseConflictingAttributes": "Chọn giá trị cho từng thuộc tính xung đột." + "nextcloudChooseConflictingAttributes": "Chọn giá trị cho từng thuộc tính xung đột.", + "nextcloudUseServerNote": "Dùng ghi chú trên máy chủ này", + "nextcloudUseServerNoteExplanation": "Liên kết bản nháp này với ghi chú đã chọn trên máy chủ, rồi đồng bộ các thay đổi cục bộ được giữ lại. Yêu cầu trùng khớp không xác nhận ai đã tạo ghi chú.", + "nextcloudExactCandidate": "Khớp chính xác với yêu cầu; chưa xác nhận danh tính.", + "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." } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 0d80daa9..3210e922 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1,4 +1,6 @@ { + "closeOtherTabs": "关闭其他标签页", + "@closeOtherTabs": {"description": "Document tab context menu action that closes every other document and comparison tab while retaining this document."}, "tocTopicFromTemplate": "从模板创建主题...", "tocSaveAsTemplate": "保存为模板", "tocTemplateDialog": "从模板创建主题", @@ -1672,9 +1674,13 @@ } } }, - "topicIsStartPageRemovalWarning": "此主题被用作实例的起始页。请查看其使用情况,并在继续前指定其他起始页。", + "topicIsHomePageRemovalWarning": "此主题是此实例的主页。从目录中移除它之前,请选择另一个主页。", + "newHomePage": "新主页", + "chooseHomePage": "选择主页", + "noHomePageReplacement": "移除主页之前,请向此实例添加或链接另一个符合条件的主题。", + "topicIsStartPageRemovalWarning": "此主题是某个实例的主页。删除其源文件之前,请指定另一个主页。", "@topicIsStartPageRemovalWarning": { - "description": "Warning that blocks deletion of a Writerside start-page topic." + "description": "Warning that blocks Safe Delete of a Writerside Home Page." }, "topicUsagesCount": "找到 {count} 处使用。", "@topicUsagesCount": { @@ -1697,9 +1703,9 @@ "@topicUsageTocElements": { "description": "Usage group label for Writerside TOC elements." }, - "topicUsageStartPages": "起始页", + "topicUsageStartPages": "主页", "@topicUsageStartPages": { - "description": "Usage group label for Writerside instance start pages." + "description": "Usage group label for Writerside instance Home Pages." }, "topicUsageTopicLinks": "主题链接", "@topicUsageTopicLinks": { @@ -2508,9 +2514,9 @@ "@errorWritersideTocInvalidMove": { "description": "Detail shown when a Writerside TOC move would create a cycle." }, - "errorWritersideStartTopicDelete": "无法删除起始主题 {topic}。请先选择其他起始页。", + "errorWritersideStartTopicDelete": "无法删除主页 {topic}。请先选择另一个主页。", "@errorWritersideStartTopicDelete": { - "description": "Detail shown when attempting to delete the start topic of a Writerside instance.", + "description": "Detail shown when attempting to delete the Home Page of a Writerside instance.", "placeholders": { "topic": { "type": "String" @@ -4945,5 +4951,12 @@ "nextcloudAttachmentReference": "服务器上的附件引用", "nextcloudAddAttachment": "添加附件…", "nextcloudDeleteAttachmentWarning": "删除此附件后,笔记中指向它的链接将不可用。保留的数据可通过本地历史记录恢复。", - "nextcloudChooseConflictingAttributes": "为每个冲突属性选择一个值。" + "nextcloudChooseConflictingAttributes": "为每个冲突属性选择一个值。", + "nextcloudUseServerNote": "使用此服务器笔记", + "nextcloudUseServerNoteExplanation": "将此草稿关联到选定的服务器笔记,然后同步保留的本地更改。请求匹配并不能确认笔记由谁创建。", + "nextcloudExactCandidate": "与请求完全匹配;身份尚未确认。", + "nextcloudPossibleCandidate": "可能与规范化后的请求匹配;身份尚未确认。", + "nextcloudSelectCandidate": "选择要检查的服务器笔记", + "nextcloudCreateSeparate": "创建独立笔记", + "nextcloudCreateSeparateWarning": "首次请求可能已在服务器上创建笔记。此操作将使用保留的本地工作创建独立笔记。不会删除任何疑似重复的笔记。" } diff --git a/lib/l10n/app_zh_CN.arb b/lib/l10n/app_zh_CN.arb index 13217ab7..d6863563 100644 --- a/lib/l10n/app_zh_CN.arb +++ b/lib/l10n/app_zh_CN.arb @@ -1,4 +1,6 @@ { + "closeOtherTabs": "关闭其他标签页", + "@closeOtherTabs": {"description": "Document tab context menu action that closes every other document and comparison tab while retaining this document."}, "tocTopicFromTemplate": "从模板创建主题...", "tocSaveAsTemplate": "保存为模板", "tocTemplateDialog": "从模板创建主题", @@ -1672,9 +1674,13 @@ } } }, - "topicIsStartPageRemovalWarning": "此主题被用作实例的起始页。请查看其使用情况,并在继续前指定其他起始页。", + "topicIsHomePageRemovalWarning": "此主题是此实例的主页。从目录中移除它之前,请选择另一个主页。", + "newHomePage": "新主页", + "chooseHomePage": "选择主页", + "noHomePageReplacement": "移除主页之前,请向此实例添加或链接另一个符合条件的主题。", + "topicIsStartPageRemovalWarning": "此主题是某个实例的主页。删除其源文件之前,请指定另一个主页。", "@topicIsStartPageRemovalWarning": { - "description": "Warning that blocks deletion of a Writerside start-page topic." + "description": "Warning that blocks Safe Delete of a Writerside Home Page." }, "topicUsagesCount": "找到 {count} 处使用。", "@topicUsagesCount": { @@ -1697,9 +1703,9 @@ "@topicUsageTocElements": { "description": "Usage group label for Writerside TOC elements." }, - "topicUsageStartPages": "起始页", + "topicUsageStartPages": "主页", "@topicUsageStartPages": { - "description": "Usage group label for Writerside instance start pages." + "description": "Usage group label for Writerside instance Home Pages." }, "topicUsageTopicLinks": "主题链接", "@topicUsageTopicLinks": { @@ -2508,9 +2514,9 @@ "@errorWritersideTocInvalidMove": { "description": "Detail shown when a Writerside TOC move would create a cycle." }, - "errorWritersideStartTopicDelete": "无法删除起始主题 {topic}。请先选择其他起始页。", + "errorWritersideStartTopicDelete": "无法删除主页 {topic}。请先选择另一个主页。", "@errorWritersideStartTopicDelete": { - "description": "Detail shown when attempting to delete the start topic of a Writerside instance.", + "description": "Detail shown when attempting to delete the Home Page of a Writerside instance.", "placeholders": { "topic": { "type": "String" @@ -4945,5 +4951,12 @@ "nextcloudAttachmentReference": "服务器上的附件引用", "nextcloudAddAttachment": "添加附件…", "nextcloudDeleteAttachmentWarning": "删除此附件后,笔记中指向它的链接将不可用。保留的数据可通过本地历史记录恢复。", - "nextcloudChooseConflictingAttributes": "为每个冲突属性选择一个值。" + "nextcloudChooseConflictingAttributes": "为每个冲突属性选择一个值。", + "nextcloudUseServerNote": "使用此服务器笔记", + "nextcloudUseServerNoteExplanation": "将此草稿关联到选定的服务器笔记,然后同步保留的本地更改。请求匹配并不能确认笔记由谁创建。", + "nextcloudExactCandidate": "与请求完全匹配;身份尚未确认。", + "nextcloudPossibleCandidate": "可能与规范化后的请求匹配;身份尚未确认。", + "nextcloudSelectCandidate": "选择要检查的服务器笔记", + "nextcloudCreateSeparate": "创建独立笔记", + "nextcloudCreateSeparateWarning": "首次请求可能已在服务器上创建笔记。此操作将使用保留的本地工作创建独立笔记。不会删除任何疑似重复的笔记。" } diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index f3bd2bab..5b1d05a6 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -139,6 +139,12 @@ abstract class AppLocalizations { Locale('zh', 'CN'), ]; + /// Document tab context menu action that closes every other document and comparison tab while retaining this document. + /// + /// In en, this message translates to: + /// **'Close other tabs'** + String get closeOtherTabs; + /// Writerside template dialog or user-template storage feedback. /// /// In en, this message translates to: @@ -2869,10 +2875,34 @@ abstract class AppLocalizations { /// **'{count, plural, =1{1 child topic will move up one level.} other{{count} child topics will move up one level.}}'** String childTopicsPromoted(int count); - /// Warning that blocks deletion of a Writerside start-page topic. + /// Home Page replacement in the Remove TOC Element dialog. + /// + /// In en, this message translates to: + /// **'This topic is the Home Page for this instance. Choose another Home Page before removing it from the TOC.'** + String get topicIsHomePageRemovalWarning; + + /// Home Page replacement in the Remove TOC Element dialog. + /// + /// In en, this message translates to: + /// **'New Home Page'** + String get newHomePage; + + /// Home Page replacement in the Remove TOC Element dialog. /// /// In en, this message translates to: - /// **'This topic is used as an instance start page. Review its usages and assign another start page before continuing.'** + /// **'Choose a Home Page'** + String get chooseHomePage; + + /// Home Page replacement in the Remove TOC Element dialog. + /// + /// In en, this message translates to: + /// **'Add or link another eligible topic to this instance before removing its Home Page.'** + String get noHomePageReplacement; + + /// Warning that blocks Safe Delete of a Writerside Home Page. + /// + /// In en, this message translates to: + /// **'This topic is a Home Page for an instance. Assign another Home Page before deleting its source file.'** String get topicIsStartPageRemovalWarning; /// Sentence reporting the number of Writerside topic usages in the removal dialog. @@ -2899,10 +2929,10 @@ abstract class AppLocalizations { /// **'TOC elements'** String get topicUsageTocElements; - /// Usage group label for Writerside instance start pages. + /// Usage group label for Writerside instance Home Pages. /// /// In en, this message translates to: - /// **'Start pages'** + /// **'Home Pages'** String get topicUsageStartPages; /// Usage group label for links to a Writerside topic. @@ -3853,10 +3883,10 @@ abstract class AppLocalizations { /// **'A TOC entry cannot be moved into itself or one of its children.'** String get errorWritersideTocInvalidMove; - /// Detail shown when attempting to delete the start topic of a Writerside instance. + /// Detail shown when attempting to delete the Home Page of a Writerside instance. /// /// In en, this message translates to: - /// **'The start topic {topic} cannot be deleted. Choose another start page first.'** + /// **'The Home Page {topic} cannot be deleted. Choose another Home Page first.'** String errorWritersideStartTopicDelete(String topic); /// Error shown when generic file deletion is attempted for a Writerside topic. @@ -8045,6 +8075,48 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Choose each conflicting attribute.'** String get nextcloudChooseConflictingAttributes; + + /// No description provided for @nextcloudUseServerNote. + /// + /// In en, this message translates to: + /// **'Use this server note'** + String get nextcloudUseServerNote; + + /// No description provided for @nextcloudUseServerNoteExplanation. + /// + /// In en, this message translates to: + /// **'Links this draft to the selected server note, then synchronizes retained local changes. A matching request does not confirm who created the note.'** + String get nextcloudUseServerNoteExplanation; + + /// No description provided for @nextcloudExactCandidate. + /// + /// In en, this message translates to: + /// **'Exact request match; identity is unconfirmed.'** + String get nextcloudExactCandidate; + + /// No description provided for @nextcloudPossibleCandidate. + /// + /// In en, this message translates to: + /// **'Possible sanitized request match; identity is unconfirmed.'** + String get nextcloudPossibleCandidate; + + /// No description provided for @nextcloudSelectCandidate. + /// + /// In en, this message translates to: + /// **'Select a server note to review'** + String get nextcloudSelectCandidate; + + /// No description provided for @nextcloudCreateSeparate. + /// + /// In en, this message translates to: + /// **'Create a separate note'** + String get nextcloudCreateSeparate; + + /// No description provided for @nextcloudCreateSeparateWarning. + /// + /// 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; } class _AppLocalizationsDelegate diff --git a/lib/l10n/generated/app_localizations_ar.dart b/lib/l10n/generated/app_localizations_ar.dart index 2d33967d..6a9cc5f3 100644 --- a/lib/l10n/generated/app_localizations_ar.dart +++ b/lib/l10n/generated/app_localizations_ar.dart @@ -10,6 +10,9 @@ import 'app_localizations.dart'; class AppLocalizationsAr extends AppLocalizations { AppLocalizationsAr([String locale = 'ar']) : super(locale); + @override + String get closeOtherTabs => 'إغلاق علامات التبويب الأخرى'; + @override String get tocTopicFromTemplate => 'موضوع من قالب...'; @@ -1534,9 +1537,23 @@ class AppLocalizationsAr extends AppLocalizations { return '$_temp0'; } + @override + String get topicIsHomePageRemovalWarning => + 'هذا الموضوع هو الصفحة الرئيسية لهذا المثيل. اختر صفحة رئيسية أخرى قبل إزالته من جدول المحتويات.'; + + @override + String get newHomePage => 'الصفحة الرئيسية الجديدة'; + + @override + String get chooseHomePage => 'اختر صفحة رئيسية'; + + @override + String get noHomePageReplacement => + 'أضف موضوعًا مؤهلًا آخر إلى هذا المثيل أو اربطه به قبل إزالة صفحته الرئيسية.'; + @override String get topicIsStartPageRemovalWarning => - 'يُستخدم هذا الموضوع كصفحة بدء لمثيل. راجع استخداماته وعيّن صفحة بدء أخرى قبل المتابعة.'; + 'هذا الموضوع هو الصفحة الرئيسية لمثيل. عيّن صفحة رئيسية أخرى قبل حذف ملفه المصدر.'; @override String topicUsagesCount(int count) { @@ -1554,7 +1571,7 @@ class AppLocalizationsAr extends AppLocalizations { String get topicUsageTocElements => 'عناصر جدول المحتويات'; @override - String get topicUsageStartPages => 'صفحات البدء'; + String get topicUsageStartPages => 'الصفحات الرئيسية'; @override String get topicUsageTopicLinks => 'روابط الموضوعات'; @@ -2188,7 +2205,7 @@ class AppLocalizationsAr extends AppLocalizations { @override String errorWritersideStartTopicDelete(String topic) { - return 'لا يمكن حذف موضوع البدء ⁨$topic⁩. اختر صفحة بدء أخرى أولًا.'; + return 'لا يمكن حذف الصفحة الرئيسية $topic. اختر صفحة رئيسية أخرى أولًا.'; } @override @@ -4777,4 +4794,28 @@ class AppLocalizationsAr extends AppLocalizations { @override String get nextcloudChooseConflictingAttributes => 'اختر قيمة لكل سمة متعارضة.'; + + @override + String get nextcloudUseServerNote => 'استخدام هذه الملاحظة على الخادم'; + + @override + String get nextcloudUseServerNoteExplanation => + 'يربط هذه المسودة بالملاحظة المحددة على الخادم، ثم يزامن التغييرات المحلية المحفوظة. تطابق الطلب لا يؤكد من أنشأ الملاحظة.'; + + @override + String get nextcloudExactCandidate => 'تطابق تام مع الطلب؛ الهوية غير مؤكدة.'; + + @override + String get nextcloudPossibleCandidate => + 'تطابق محتمل بعد تنقية الطلب؛ الهوية غير مؤكدة.'; + + @override + String get nextcloudSelectCandidate => 'اختر ملاحظة على الخادم لمراجعتها'; + + @override + String get nextcloudCreateSeparate => 'إنشاء ملاحظة منفصلة'; + + @override + String get nextcloudCreateSeparateWarning => + 'قد يكون الطلب الأول قد أنشأ ملاحظة على الخادم بالفعل. ينشئ هذا ملاحظة منفصلة بعملك المحلي المحفوظ. لن تُحذف أي ملاحظة يُشتبه بأنها مكررة.'; } diff --git a/lib/l10n/generated/app_localizations_de.dart b/lib/l10n/generated/app_localizations_de.dart index 60b26431..81c2afce 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -10,6 +10,9 @@ import 'app_localizations.dart'; class AppLocalizationsDe extends AppLocalizations { AppLocalizationsDe([String locale = 'de']) : super(locale); + @override + String get closeOtherTabs => 'Andere Tabs schließen'; + @override String get tocTopicFromTemplate => 'Thema aus Vorlage...'; @@ -1549,9 +1552,23 @@ class AppLocalizationsDe extends AppLocalizations { return '$_temp0'; } + @override + String get topicIsHomePageRemovalWarning => + 'Dieses Thema ist die Startseite dieser Instanz. Wählen Sie eine andere Startseite, bevor Sie es aus dem Inhaltsverzeichnis entfernen.'; + + @override + String get newHomePage => 'Neue Startseite'; + + @override + String get chooseHomePage => 'Startseite auswählen'; + + @override + String get noHomePageReplacement => + 'Fügen Sie dieser Instanz ein anderes geeignetes Thema hinzu oder verknüpfen Sie es, bevor Sie ihre Startseite entfernen.'; + @override String get topicIsStartPageRemovalWarning => - 'Dieses Thema wird als Startseite einer Instanz verwendet. Prüfen Sie seine Verwendungen und weisen Sie eine andere Startseite zu, bevor Sie fortfahren.'; + 'Dieses Thema ist die Startseite einer Instanz. Legen Sie eine andere Startseite fest, bevor Sie seine Quelldatei löschen.'; @override String topicUsagesCount(int count) { @@ -2195,7 +2212,7 @@ class AppLocalizationsDe extends AppLocalizations { @override String errorWritersideStartTopicDelete(String topic) { - return 'Das Startthema $topic kann nicht gelöscht werden. Wählen Sie zuerst eine andere Startseite aus.'; + return 'Die Startseite $topic kann nicht gelöscht werden. Wählen Sie zuerst eine andere Startseite.'; } @override @@ -4789,4 +4806,29 @@ class AppLocalizationsDe extends AppLocalizations { @override String get nextcloudChooseConflictingAttributes => 'Wählen Sie jeden widersprüchlichen Wert.'; + + @override + String get nextcloudUseServerNote => 'Diese Servernotiz verwenden'; + + @override + String get nextcloudUseServerNoteExplanation => + 'Verknüpft diesen Entwurf mit der ausgewählten Servernotiz und synchronisiert anschließend die behaltenen lokalen Änderungen. Ein passender Antrag bestätigt nicht, wer die Notiz erstellt hat.'; + + @override + String get nextcloudExactCandidate => + 'Exakte Übereinstimmung mit der Anfrage; Identität unbestätigt.'; + + @override + String get nextcloudPossibleCandidate => + 'Mögliche Übereinstimmung nach Bereinigung der Anfrage; Identität unbestätigt.'; + + @override + String get nextcloudSelectCandidate => 'Servernotiz zur Prüfung auswählen'; + + @override + String get nextcloudCreateSeparate => 'Separate Notiz erstellen'; + + @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.'; } diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index 0d11d477..ae6f199d 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -10,6 +10,9 @@ import 'app_localizations.dart'; class AppLocalizationsEn extends AppLocalizations { AppLocalizationsEn([String locale = 'en']) : super(locale); + @override + String get closeOtherTabs => 'Close other tabs'; + @override String get tocTopicFromTemplate => 'Topic from Template...'; @@ -1525,9 +1528,23 @@ class AppLocalizationsEn extends AppLocalizations { return '$_temp0'; } + @override + String get topicIsHomePageRemovalWarning => + 'This topic is the Home Page for this instance. Choose another Home Page before removing it from the TOC.'; + + @override + String get newHomePage => 'New Home Page'; + + @override + String get chooseHomePage => 'Choose a Home Page'; + + @override + String get noHomePageReplacement => + 'Add or link another eligible topic to this instance before removing its Home Page.'; + @override String get topicIsStartPageRemovalWarning => - 'This topic is used as an instance start page. Review its usages and assign another start page before continuing.'; + 'This topic is a Home Page for an instance. Assign another Home Page before deleting its source file.'; @override String topicUsagesCount(int count) { @@ -1546,7 +1563,7 @@ class AppLocalizationsEn extends AppLocalizations { String get topicUsageTocElements => 'TOC elements'; @override - String get topicUsageStartPages => 'Start pages'; + String get topicUsageStartPages => 'Home Pages'; @override String get topicUsageTopicLinks => 'Topic links'; @@ -2171,7 +2188,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String errorWritersideStartTopicDelete(String topic) { - return 'The start topic $topic cannot be deleted. Choose another start page first.'; + return 'The Home Page $topic cannot be deleted. Choose another Home Page first.'; } @override @@ -4760,4 +4777,29 @@ class AppLocalizationsEn extends AppLocalizations { @override String get nextcloudChooseConflictingAttributes => 'Choose each conflicting attribute.'; + + @override + String get nextcloudUseServerNote => 'Use this server note'; + + @override + String get nextcloudUseServerNoteExplanation => + 'Links this draft to the selected server note, then synchronizes retained local changes. A matching request does not confirm who created the note.'; + + @override + String get nextcloudExactCandidate => + 'Exact request match; identity is unconfirmed.'; + + @override + String get nextcloudPossibleCandidate => + 'Possible sanitized request match; identity is unconfirmed.'; + + @override + String get nextcloudSelectCandidate => 'Select a server note to review'; + + @override + String get nextcloudCreateSeparate => 'Create a separate note'; + + @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.'; } diff --git a/lib/l10n/generated/app_localizations_es.dart b/lib/l10n/generated/app_localizations_es.dart index 37ebafce..744490fa 100644 --- a/lib/l10n/generated/app_localizations_es.dart +++ b/lib/l10n/generated/app_localizations_es.dart @@ -10,6 +10,9 @@ import 'app_localizations.dart'; class AppLocalizationsEs extends AppLocalizations { AppLocalizationsEs([String locale = 'es']) : super(locale); + @override + String get closeOtherTabs => 'Cerrar las otras pestañas'; + @override String get tocTopicFromTemplate => 'Tema desde plantilla...'; @@ -1546,9 +1549,23 @@ class AppLocalizationsEs extends AppLocalizations { return '$_temp0'; } + @override + String get topicIsHomePageRemovalWarning => + 'Este tema es la página de inicio de esta instancia. Elige otra página de inicio antes de quitarlo de la tabla de contenido.'; + + @override + String get newHomePage => 'Nueva página de inicio'; + + @override + String get chooseHomePage => 'Elegir página de inicio'; + + @override + String get noHomePageReplacement => + 'Añade o vincula otro tema válido a esta instancia antes de quitar su página de inicio.'; + @override String get topicIsStartPageRemovalWarning => - 'Este tema se usa como página de inicio de una instancia. Revisa sus usos y asigna otra página de inicio antes de continuar.'; + 'Este tema es la página de inicio de una instancia. Asigna otra página de inicio antes de eliminar su archivo de origen.'; @override String topicUsagesCount(int count) { @@ -2193,7 +2210,7 @@ class AppLocalizationsEs extends AppLocalizations { @override String errorWritersideStartTopicDelete(String topic) { - return 'El tema de inicio $topic no se puede eliminar. Elija primero otra página de inicio.'; + return 'No se puede eliminar la página de inicio $topic. Elige otra página de inicio primero.'; } @override @@ -4794,4 +4811,30 @@ class AppLocalizationsEs extends AppLocalizations { @override String get nextcloudChooseConflictingAttributes => 'Elige cada atributo en conflicto.'; + + @override + String get nextcloudUseServerNote => 'Usar esta nota del servidor'; + + @override + String get nextcloudUseServerNoteExplanation => + 'Vincula este borrador con la nota seleccionada del servidor y luego sincroniza los cambios locales conservados. Una solicitud coincidente no confirma quién creó la nota.'; + + @override + String get nextcloudExactCandidate => + 'Coincidencia exacta con la solicitud; identidad sin confirmar.'; + + @override + String get nextcloudPossibleCandidate => + 'Posible coincidencia con la solicitud saneada; identidad sin confirmar.'; + + @override + String get nextcloudSelectCandidate => + 'Seleccionar una nota del servidor para revisarla'; + + @override + String get nextcloudCreateSeparate => 'Crear una nota aparte'; + + @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.'; } diff --git a/lib/l10n/generated/app_localizations_et.dart b/lib/l10n/generated/app_localizations_et.dart index df236e8c..1aa28afd 100644 --- a/lib/l10n/generated/app_localizations_et.dart +++ b/lib/l10n/generated/app_localizations_et.dart @@ -10,6 +10,9 @@ import 'app_localizations.dart'; class AppLocalizationsEt extends AppLocalizations { AppLocalizationsEt([String locale = 'et']) : super(locale); + @override + String get closeOtherTabs => 'Sulge teised vahelehed'; + @override String get tocTopicFromTemplate => 'Teema mallist...'; @@ -1529,9 +1532,23 @@ class AppLocalizationsEt extends AppLocalizations { return '$_temp0'; } + @override + String get topicIsHomePageRemovalWarning => + 'See teema on selle eksemplari avaleht. Vali teine avaleht enne selle eemaldamist sisukorrast.'; + + @override + String get newHomePage => 'Uus avaleht'; + + @override + String get chooseHomePage => 'Vali avaleht'; + + @override + String get noHomePageReplacement => + 'Lisa või lingi sellele eksemplarile teine sobiv teema enne selle avalehe eemaldamist.'; + @override String get topicIsStartPageRemovalWarning => - 'Seda teemat kasutatakse eksemplari avalehena. Enne jätkamist vaata kasutuskohad üle ja määra teine avaleht.'; + 'See teema on eksemplari avaleht. Määra teine avaleht enne selle lähtefaili kustutamist.'; @override String topicUsagesCount(int count) { @@ -2168,7 +2185,7 @@ class AppLocalizationsEt extends AppLocalizations { @override String errorWritersideStartTopicDelete(String topic) { - return 'Avaleheks määratud teemat „$topic” ei saa kustutada. Vali esmalt teine avaleht.'; + return 'Avaleht $topic ei saa kustutada. Vali esmalt teine avaleht.'; } @override @@ -4746,4 +4763,29 @@ class AppLocalizationsEt extends AppLocalizations { @override String get nextcloudChooseConflictingAttributes => 'Valige iga vastuolulise atribuudi väärtus.'; + + @override + String get nextcloudUseServerNote => 'Kasuta seda serverimärkust'; + + @override + String get nextcloudUseServerNoteExplanation => + 'Seob mustandi valitud serverimärkusega ja sünkroonib seejärel säilitatud kohalikud muudatused. Päringu vastavus ei kinnita märkuse loojat.'; + + @override + String get nextcloudExactCandidate => + 'Täpne päringu vaste; identiteet on kinnitamata.'; + + @override + String get nextcloudPossibleCandidate => + 'Võimalik puhastatud päringu vaste; identiteet on kinnitamata.'; + + @override + String get nextcloudSelectCandidate => 'Vali ülevaatamiseks serverimärkus'; + + @override + String get nextcloudCreateSeparate => 'Loo eraldi märkus'; + + @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.'; } diff --git a/lib/l10n/generated/app_localizations_fa.dart b/lib/l10n/generated/app_localizations_fa.dart index c20ca30c..5255ea37 100644 --- a/lib/l10n/generated/app_localizations_fa.dart +++ b/lib/l10n/generated/app_localizations_fa.dart @@ -10,6 +10,9 @@ import 'app_localizations.dart'; class AppLocalizationsFa extends AppLocalizations { AppLocalizationsFa([String locale = 'fa']) : super(locale); + @override + String get closeOtherTabs => 'بستن زبانه‌های دیگر'; + @override String get tocTopicFromTemplate => 'موضوع از الگو...'; @@ -1557,9 +1560,23 @@ class AppLocalizationsFa extends AppLocalizations { return '$_temp0'; } + @override + String get topicIsHomePageRemovalWarning => + 'این موضوع صفحهٔ اصلی این نمونه است. پیش از حذف آن از فهرست مطالب، صفحهٔ اصلی دیگری انتخاب کنید.'; + + @override + String get newHomePage => 'صفحهٔ اصلی جدید'; + + @override + String get chooseHomePage => 'انتخاب صفحهٔ اصلی'; + + @override + String get noHomePageReplacement => + 'پیش از حذف صفحهٔ اصلی، موضوع مناسب دیگری به این نمونه اضافه یا پیوند دهید.'; + @override String get topicIsStartPageRemovalWarning => - 'این موضوع به‌عنوان صفحهٔ آغاز یک نمونه استفاده می‌شود. موارد استفاده را مرور و پیش از ادامه صفحهٔ آغاز دیگری تعیین کنید.'; + 'این موضوع صفحهٔ اصلی یک نمونه است. پیش از حذف فایل منبع آن، صفحهٔ اصلی دیگری تعیین کنید.'; @override String topicUsagesCount(int count) { @@ -1581,7 +1598,7 @@ class AppLocalizationsFa extends AppLocalizations { String get topicUsageTocElements => 'عناصر فهرست مطالب'; @override - String get topicUsageStartPages => 'صفحه‌های آغاز'; + String get topicUsageStartPages => 'صفحه‌های اصلی'; @override String get topicUsageTopicLinks => 'پیوندهای موضوع'; @@ -2209,7 +2226,7 @@ class AppLocalizationsFa extends AppLocalizations { @override String errorWritersideStartTopicDelete(String topic) { - return 'موضوع شروع ⁨$topic⁩ را نمی‌توان حذف کرد. ابتدا صفحهٔ شروع دیگری را انتخاب کنید.'; + return 'صفحهٔ اصلی $topic قابل حذف نیست. ابتدا صفحهٔ اصلی دیگری انتخاب کنید.'; } @override @@ -4783,4 +4800,30 @@ class AppLocalizationsFa extends AppLocalizations { @override String get nextcloudChooseConflictingAttributes => 'مقدار هر ویژگی متعارض را انتخاب کنید.'; + + @override + String get nextcloudUseServerNote => 'استفاده از این یادداشت سرور'; + + @override + String get nextcloudUseServerNoteExplanation => + 'این پیش‌نویس را به یادداشت انتخاب‌شدهٔ سرور پیوند می‌دهد و سپس تغییرات محلی حفظ‌شده را همگام می‌کند. تطابق درخواست مشخص نمی‌کند چه کسی یادداشت را ایجاد کرده است.'; + + @override + String get nextcloudExactCandidate => + 'تطابق دقیق درخواست؛ هویت تأیید نشده است.'; + + @override + String get nextcloudPossibleCandidate => + 'تطابق احتمالی درخواست پاک‌سازی‌شده؛ هویت تأیید نشده است.'; + + @override + String get nextcloudSelectCandidate => + 'یک یادداشت سرور را برای بررسی انتخاب کنید'; + + @override + String get nextcloudCreateSeparate => 'ایجاد یک یادداشت جداگانه'; + + @override + String get nextcloudCreateSeparateWarning => + 'ممکن است درخواست نخست قبلاً یک یادداشت سرور ایجاد کرده باشد. این کار یک یادداشت جداگانه با کار محلی حفظ‌شدهٔ شما ایجاد می‌کند. هیچ یادداشت مشکوک به تکراری بودن حذف نمی‌شود.'; } diff --git a/lib/l10n/generated/app_localizations_fr.dart b/lib/l10n/generated/app_localizations_fr.dart index a7937f35..ad479e4f 100644 --- a/lib/l10n/generated/app_localizations_fr.dart +++ b/lib/l10n/generated/app_localizations_fr.dart @@ -10,6 +10,9 @@ import 'app_localizations.dart'; class AppLocalizationsFr extends AppLocalizations { AppLocalizationsFr([String locale = 'fr']) : super(locale); + @override + String get closeOtherTabs => 'Fermer les autres onglets'; + @override String get tocTopicFromTemplate => 'Sujet à partir d’un modèle...'; @@ -1550,9 +1553,23 @@ class AppLocalizationsFr extends AppLocalizations { return '$_temp0'; } + @override + String get topicIsHomePageRemovalWarning => + 'Ce sujet est la page d’accueil de cette instance. Choisissez une autre page d’accueil avant de le retirer de la table des matières.'; + + @override + String get newHomePage => 'Nouvelle page d’accueil'; + + @override + String get chooseHomePage => 'Choisir une page d’accueil'; + + @override + String get noHomePageReplacement => + 'Ajoutez ou liez un autre sujet admissible à cette instance avant de retirer sa page d’accueil.'; + @override String get topicIsStartPageRemovalWarning => - 'Ce sujet sert de page de démarrage à une instance. Examinez ses utilisations et attribuez une autre page de démarrage avant de continuer.'; + 'Ce sujet est la page d’accueil d’une instance. Définissez une autre page d’accueil avant de supprimer son fichier source.'; @override String topicUsagesCount(int count) { @@ -1571,7 +1588,7 @@ class AppLocalizationsFr extends AppLocalizations { String get topicUsageTocElements => 'Éléments de la table des matières'; @override - String get topicUsageStartPages => 'Pages de démarrage'; + String get topicUsageStartPages => 'Pages d’accueil'; @override String get topicUsageTopicLinks => 'Liens vers le sujet'; @@ -2199,7 +2216,7 @@ class AppLocalizationsFr extends AppLocalizations { @override String errorWritersideStartTopicDelete(String topic) { - return 'Le sujet de démarrage $topic ne peut pas être supprimé. Choisissez d’abord une autre page de démarrage.'; + return 'La page d’accueil $topic ne peut pas être supprimée. Choisissez d’abord une autre page d’accueil.'; } @override @@ -4796,4 +4813,30 @@ class AppLocalizationsFr extends AppLocalizations { @override String get nextcloudChooseConflictingAttributes => 'Choisissez chaque attribut en conflit.'; + + @override + String get nextcloudUseServerNote => 'Utiliser cette note du serveur'; + + @override + String get nextcloudUseServerNoteExplanation => + 'Lie ce brouillon à la note sélectionnée sur le serveur, puis synchronise les modifications locales conservées. Une requête identique ne confirme pas qui a créé la note.'; + + @override + String get nextcloudExactCandidate => + 'Correspondance exacte avec la requête ; identité non confirmée.'; + + @override + String get nextcloudPossibleCandidate => + 'Correspondance possible après normalisation ; identité non confirmée.'; + + @override + String get nextcloudSelectCandidate => + 'Sélectionner une note du serveur à examiner'; + + @override + String get nextcloudCreateSeparate => 'Créer une note distincte'; + + @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é.'; } diff --git a/lib/l10n/generated/app_localizations_hi.dart b/lib/l10n/generated/app_localizations_hi.dart index 27c0cfcb..864bd746 100644 --- a/lib/l10n/generated/app_localizations_hi.dart +++ b/lib/l10n/generated/app_localizations_hi.dart @@ -10,6 +10,9 @@ import 'app_localizations.dart'; class AppLocalizationsHi extends AppLocalizations { AppLocalizationsHi([String locale = 'hi']) : super(locale); + @override + String get closeOtherTabs => 'अन्य टैब बंद करें'; + @override String get tocTopicFromTemplate => 'टेम्पलेट से विषय...'; @@ -1525,9 +1528,23 @@ class AppLocalizationsHi extends AppLocalizations { return '$_temp0'; } + @override + String get topicIsHomePageRemovalWarning => + 'यह विषय इस इंस्टेंस का मुखपृष्ठ है। इसे विषय-सूची से हटाने से पहले कोई दूसरा मुखपृष्ठ चुनें।'; + + @override + String get newHomePage => 'नया मुखपृष्ठ'; + + @override + String get chooseHomePage => 'मुखपृष्ठ चुनें'; + + @override + String get noHomePageReplacement => + 'इसका मुखपृष्ठ हटाने से पहले इस इंस्टेंस में कोई दूसरा योग्य विषय जोड़ें या लिंक करें।'; + @override String get topicIsStartPageRemovalWarning => - 'यह विषय किसी इंस्टेंस के प्रारंभ पृष्ठ के रूप में उपयोग हो रहा है। इसके उपयोगों की समीक्षा करें और आगे बढ़ने से पहले कोई दूसरा प्रारंभ पृष्ठ निर्धारित करें।'; + 'यह विषय किसी इंस्टेंस का मुखपृष्ठ है। इसकी स्रोत फ़ाइल हटाने से पहले कोई दूसरा मुखपृष्ठ निर्धारित करें।'; @override String topicUsagesCount(int count) { @@ -1545,7 +1562,7 @@ class AppLocalizationsHi extends AppLocalizations { String get topicUsageTocElements => 'TOC तत्व'; @override - String get topicUsageStartPages => 'प्रारंभ पृष्ठ'; + String get topicUsageStartPages => 'मुखपृष्ठ'; @override String get topicUsageTopicLinks => 'विषय लिंक'; @@ -2165,7 +2182,7 @@ class AppLocalizationsHi extends AppLocalizations { @override String errorWritersideStartTopicDelete(String topic) { - return 'आरंभिक विषय $topic को हटाया नहीं जा सकता। पहले कोई दूसरा आरंभ पेज चुनें।'; + return 'मुखपृष्ठ $topic को हटाया नहीं जा सकता। पहले कोई दूसरा मुखपृष्ठ चुनें।'; } @override @@ -4736,4 +4753,29 @@ class AppLocalizationsHi extends AppLocalizations { @override String get nextcloudChooseConflictingAttributes => 'हर परस्पर विरोधी विशेषता का मान चुनें।'; + + @override + String get nextcloudUseServerNote => 'सर्वर के इस नोट का उपयोग करें'; + + @override + String get nextcloudUseServerNoteExplanation => + 'इस मसौदे को सर्वर के चुने हुए नोट से जोड़ता है, फिर सुरक्षित स्थानीय बदलाव समन्वयित करता है। अनुरोध का मिलना यह पुष्टि नहीं करता कि नोट किसने बनाया था।'; + + @override + String get nextcloudExactCandidate => + 'अनुरोध से पूरी तरह मेल; पहचान की पुष्टि नहीं हुई है।'; + + @override + String get nextcloudPossibleCandidate => + 'संशोधित अनुरोध से संभावित मेल; पहचान की पुष्टि नहीं हुई है।'; + + @override + String get nextcloudSelectCandidate => 'समीक्षा के लिए सर्वर का नोट चुनें'; + + @override + String get nextcloudCreateSeparate => 'अलग नोट बनाएँ'; + + @override + String get nextcloudCreateSeparateWarning => + 'पहले अनुरोध ने सर्वर पर पहले ही नोट बना दिया हो सकता है। यह आपके सुरक्षित स्थानीय काम के साथ अलग नोट बनाता है। किसी संभावित प्रतिलिपि को हटाया नहीं जाएगा।'; } diff --git a/lib/l10n/generated/app_localizations_id.dart b/lib/l10n/generated/app_localizations_id.dart index c03e1b36..350e4af3 100644 --- a/lib/l10n/generated/app_localizations_id.dart +++ b/lib/l10n/generated/app_localizations_id.dart @@ -10,6 +10,9 @@ import 'app_localizations.dart'; class AppLocalizationsId extends AppLocalizations { AppLocalizationsId([String locale = 'id']) : super(locale); + @override + String get closeOtherTabs => 'Tutup tab lainnya'; + @override String get tocTopicFromTemplate => 'Topik dari Templat...'; @@ -1530,9 +1533,23 @@ class AppLocalizationsId extends AppLocalizations { return '$_temp0'; } + @override + String get topicIsHomePageRemovalWarning => + 'Topik ini adalah halaman utama instance ini. Pilih halaman utama lain sebelum menghapusnya dari daftar isi.'; + + @override + String get newHomePage => 'Halaman utama baru'; + + @override + String get chooseHomePage => 'Pilih halaman utama'; + + @override + String get noHomePageReplacement => + 'Tambahkan atau tautkan topik lain yang memenuhi syarat ke instance ini sebelum menghapus halaman utamanya.'; + @override String get topicIsStartPageRemovalWarning => - 'Topik ini digunakan sebagai halaman awal instance. Tinjau penggunaannya dan tetapkan halaman awal lain sebelum melanjutkan.'; + 'Topik ini adalah halaman utama sebuah instance. Tetapkan halaman utama lain sebelum menghapus file sumbernya.'; @override String topicUsagesCount(int count) { @@ -1550,7 +1567,7 @@ class AppLocalizationsId extends AppLocalizations { String get topicUsageTocElements => 'elemen TOC'; @override - String get topicUsageStartPages => 'Halaman awal'; + String get topicUsageStartPages => 'Halaman utama'; @override String get topicUsageTopicLinks => 'Tautan topik'; @@ -2170,7 +2187,7 @@ class AppLocalizationsId extends AppLocalizations { @override String errorWritersideStartTopicDelete(String topic) { - return 'Topik awal $topic tidak dapat dihapus. Pilih halaman awal lain terlebih dahulu.'; + return 'Halaman utama $topic tidak dapat dihapus. Pilih halaman utama lain terlebih dahulu.'; } @override @@ -4746,4 +4763,29 @@ class AppLocalizationsId extends AppLocalizations { @override String get nextcloudChooseConflictingAttributes => 'Pilih setiap atribut yang berkonflik.'; + + @override + String get nextcloudUseServerNote => 'Gunakan catatan server ini'; + + @override + String get nextcloudUseServerNoteExplanation => + 'Menautkan draf ini ke catatan server yang dipilih, lalu menyinkronkan perubahan lokal yang dipertahankan. Permintaan yang cocok tidak memastikan siapa yang membuat catatan.'; + + @override + String get nextcloudExactCandidate => + 'Sama persis dengan permintaan; identitas belum dikonfirmasi.'; + + @override + String get nextcloudPossibleCandidate => + 'Kemungkinan cocok setelah permintaan disanitasi; identitas belum dikonfirmasi.'; + + @override + String get nextcloudSelectCandidate => 'Pilih catatan server untuk ditinjau'; + + @override + String get nextcloudCreateSeparate => 'Buat catatan terpisah'; + + @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.'; } diff --git a/lib/l10n/generated/app_localizations_it.dart b/lib/l10n/generated/app_localizations_it.dart index f1c3bcd4..9d934a04 100644 --- a/lib/l10n/generated/app_localizations_it.dart +++ b/lib/l10n/generated/app_localizations_it.dart @@ -10,6 +10,9 @@ import 'app_localizations.dart'; class AppLocalizationsIt extends AppLocalizations { AppLocalizationsIt([String locale = 'it']) : super(locale); + @override + String get closeOtherTabs => 'Chiudi le altre schede'; + @override String get tocTopicFromTemplate => 'Argomento da modello...'; @@ -1543,9 +1546,23 @@ class AppLocalizationsIt extends AppLocalizations { return '$_temp0'; } + @override + String get topicIsHomePageRemovalWarning => + 'Questo argomento è la pagina iniziale di questa istanza. Scegli un’altra pagina iniziale prima di rimuoverlo dal sommario.'; + + @override + String get newHomePage => 'Nuova pagina iniziale'; + + @override + String get chooseHomePage => 'Scegli una pagina iniziale'; + + @override + String get noHomePageReplacement => + 'Aggiungi o collega un altro argomento idoneo a questa istanza prima di rimuovere la sua pagina iniziale.'; + @override String get topicIsStartPageRemovalWarning => - 'Questo argomento è utilizzato come pagina iniziale di un’istanza. Esaminane gli utilizzi e assegna un’altra pagina iniziale prima di continuare.'; + 'Questo argomento è la pagina iniziale di un’istanza. Imposta un’altra pagina iniziale prima di eliminare il suo file sorgente.'; @override String topicUsagesCount(int count) { @@ -2191,7 +2208,7 @@ class AppLocalizationsIt extends AppLocalizations { @override String errorWritersideStartTopicDelete(String topic) { - return 'L\'argomento iniziale $topic non può essere eliminato. Scegli prima un\'altra pagina iniziale.'; + return 'La pagina iniziale $topic non può essere eliminata. Scegli prima un’altra pagina iniziale.'; } @override @@ -4787,4 +4804,30 @@ class AppLocalizationsIt extends AppLocalizations { @override String get nextcloudChooseConflictingAttributes => 'Scegli ogni attributo in conflitto.'; + + @override + String get nextcloudUseServerNote => 'Usa questa nota del server'; + + @override + String get nextcloudUseServerNoteExplanation => + 'Collega questa bozza alla nota selezionata sul server, poi sincronizza le modifiche locali conservate. Una richiesta corrispondente non conferma chi ha creato la nota.'; + + @override + String get nextcloudExactCandidate => + 'Corrispondenza esatta con la richiesta; identità non confermata.'; + + @override + String get nextcloudPossibleCandidate => + 'Possibile corrispondenza dopo la normalizzazione; identità non confermata.'; + + @override + String get nextcloudSelectCandidate => + 'Seleziona una nota del server da esaminare'; + + @override + String get nextcloudCreateSeparate => 'Crea una nota separata'; + + @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.'; } diff --git a/lib/l10n/generated/app_localizations_ja.dart b/lib/l10n/generated/app_localizations_ja.dart index 7c5fc03b..a4c4d779 100644 --- a/lib/l10n/generated/app_localizations_ja.dart +++ b/lib/l10n/generated/app_localizations_ja.dart @@ -10,6 +10,9 @@ import 'app_localizations.dart'; class AppLocalizationsJa extends AppLocalizations { AppLocalizationsJa([String locale = 'ja']) : super(locale); + @override + String get closeOtherTabs => '他のタブを閉じる'; + @override String get tocTopicFromTemplate => 'テンプレートからトピック...'; @@ -1482,9 +1485,23 @@ class AppLocalizationsJa extends AppLocalizations { return '$_temp0'; } + @override + String get topicIsHomePageRemovalWarning => + 'このトピックはこのインスタンスのホームページです。目次から削除する前に、別のホームページを選択してください。'; + + @override + String get newHomePage => '新しいホームページ'; + + @override + String get chooseHomePage => 'ホームページを選択'; + + @override + String get noHomePageReplacement => + 'ホームページを削除する前に、このインスタンスに別の適切なトピックを追加またはリンクしてください。'; + @override String get topicIsStartPageRemovalWarning => - 'このトピックはインスタンスの開始ページとして使用されています。使用箇所を確認し、続行する前に別の開始ページを割り当ててください。'; + 'このトピックはインスタンスのホームページです。ソースファイルを削除する前に、別のホームページを設定してください。'; @override String topicUsagesCount(int count) { @@ -1501,7 +1518,7 @@ class AppLocalizationsJa extends AppLocalizations { String get topicUsageTocElements => '目次要素'; @override - String get topicUsageStartPages => '開始ページ'; + String get topicUsageStartPages => 'ホームページ'; @override String get topicUsageTopicLinks => 'トピックリンク'; @@ -2100,7 +2117,7 @@ class AppLocalizationsJa extends AppLocalizations { @override String errorWritersideStartTopicDelete(String topic) { - return '開始トピック $topic は削除できません。先に別の開始ページを選択してください。'; + return 'ホームページ $topic は削除できません。先に別のホームページを選択してください。'; } @override @@ -4615,4 +4632,27 @@ class AppLocalizationsJa extends AppLocalizations { @override String get nextcloudChooseConflictingAttributes => '競合する各属性の値を選択してください。'; + + @override + String get nextcloudUseServerNote => 'このサーバーノートを使用'; + + @override + String get nextcloudUseServerNoteExplanation => + 'この下書きを選択したサーバーノートにリンクし、保持したローカル変更を同期します。リクエストの一致だけでは作成者を確認できません。'; + + @override + String get nextcloudExactCandidate => 'リクエストと完全一致。識別情報は未確認です。'; + + @override + String get nextcloudPossibleCandidate => '正規化されたリクエストと一致する可能性あり。識別情報は未確認です。'; + + @override + String get nextcloudSelectCandidate => '確認するサーバーノートを選択'; + + @override + String get nextcloudCreateSeparate => '別のノートを作成'; + + @override + String get nextcloudCreateSeparateWarning => + '最初のリクエストですでにサーバーノートが作成された可能性があります。保持したローカル作業から別のノートを作成します。重複の疑いがあるノートは削除されません。'; } diff --git a/lib/l10n/generated/app_localizations_ko.dart b/lib/l10n/generated/app_localizations_ko.dart index 3c9a55cf..0e9a24d3 100644 --- a/lib/l10n/generated/app_localizations_ko.dart +++ b/lib/l10n/generated/app_localizations_ko.dart @@ -10,6 +10,9 @@ import 'app_localizations.dart'; class AppLocalizationsKo extends AppLocalizations { AppLocalizationsKo([String locale = 'ko']) : super(locale); + @override + String get closeOtherTabs => '다른 탭 닫기'; + @override String get tocTopicFromTemplate => '템플릿에서 토픽...'; @@ -1479,9 +1482,23 @@ class AppLocalizationsKo extends AppLocalizations { return '$_temp0'; } + @override + String get topicIsHomePageRemovalWarning => + '이 토픽은 이 인스턴스의 홈 페이지입니다. 목차에서 제거하기 전에 다른 홈 페이지를 선택하세요.'; + + @override + String get newHomePage => '새 홈 페이지'; + + @override + String get chooseHomePage => '홈 페이지 선택'; + + @override + String get noHomePageReplacement => + '홈 페이지를 제거하기 전에 이 인스턴스에 다른 적합한 토픽을 추가하거나 연결하세요.'; + @override String get topicIsStartPageRemovalWarning => - '이 토픽은 인스턴스 시작 페이지로 사용됩니다. 계속하기 전에 사용처를 검토하고 다른 시작 페이지를 지정하세요.'; + '이 토픽은 인스턴스의 홈 페이지입니다. 소스 파일을 삭제하기 전에 다른 홈 페이지를 지정하세요.'; @override String topicUsagesCount(int count) { @@ -1498,7 +1515,7 @@ class AppLocalizationsKo extends AppLocalizations { String get topicUsageTocElements => '목차 요소'; @override - String get topicUsageStartPages => '시작 페이지'; + String get topicUsageStartPages => '홈 페이지'; @override String get topicUsageTopicLinks => '토픽 링크'; @@ -2096,7 +2113,7 @@ class AppLocalizationsKo extends AppLocalizations { @override String errorWritersideStartTopicDelete(String topic) { - return '시작 토픽 $topic을(를) 삭제할 수 없습니다. 먼저 다른 시작 페이지를 선택하세요.'; + return '홈 페이지 $topic을(를) 삭제할 수 없습니다. 먼저 다른 홈 페이지를 선택하세요.'; } @override @@ -4609,4 +4626,28 @@ class AppLocalizationsKo extends AppLocalizations { @override String get nextcloudChooseConflictingAttributes => '충돌하는 각 속성의 값을 선택하세요.'; + + @override + String get nextcloudUseServerNote => '이 서버 노트 사용'; + + @override + String get nextcloudUseServerNoteExplanation => + '이 초안을 선택한 서버 노트에 연결한 다음 보존된 로컬 변경 사항을 동기화합니다. 요청이 일치해도 누가 노트를 만들었는지는 확인되지 않습니다.'; + + @override + String get nextcloudExactCandidate => '요청과 정확히 일치합니다. 동일한 노트인지 확인되지 않았습니다.'; + + @override + String get nextcloudPossibleCandidate => + '정리된 요청과 일치할 수 있습니다. 동일한 노트인지 확인되지 않았습니다.'; + + @override + String get nextcloudSelectCandidate => '검토할 서버 노트 선택'; + + @override + String get nextcloudCreateSeparate => '별도 노트 만들기'; + + @override + String get nextcloudCreateSeparateWarning => + '첫 요청으로 서버 노트가 이미 만들어졌을 수 있습니다. 보존된 로컬 작업으로 별도 노트를 만듭니다. 중복으로 의심되는 노트는 삭제하지 않습니다.'; } diff --git a/lib/l10n/generated/app_localizations_nb.dart b/lib/l10n/generated/app_localizations_nb.dart index 68ee4d89..01153870 100644 --- a/lib/l10n/generated/app_localizations_nb.dart +++ b/lib/l10n/generated/app_localizations_nb.dart @@ -10,6 +10,9 @@ import 'app_localizations.dart'; class AppLocalizationsNb extends AppLocalizations { AppLocalizationsNb([String locale = 'nb']) : super(locale); + @override + String get closeOtherTabs => 'Lukk andre faner'; + @override String get tocTopicFromTemplate => 'Emne fra mal...'; @@ -1532,9 +1535,23 @@ class AppLocalizationsNb extends AppLocalizations { return '$_temp0'; } + @override + String get topicIsHomePageRemovalWarning => + 'Dette emnet er startsiden for denne instansen. Velg en annen startside før du fjerner det fra innholdsfortegnelsen.'; + + @override + String get newHomePage => 'Ny startside'; + + @override + String get chooseHomePage => 'Velg en startside'; + + @override + String get noHomePageReplacement => + 'Legg til eller koble et annet egnet emne til denne instansen før du fjerner startsiden.'; + @override String get topicIsStartPageRemovalWarning => - 'Dette emnet brukes som startside for en instans. Se gjennom bruken, og angi en annen startside før du fortsetter.'; + 'Dette emnet er startsiden for en instans. Angi en annen startside før du sletter kildefilen.'; @override String topicUsagesCount(int count) { @@ -2173,7 +2190,7 @@ class AppLocalizationsNb extends AppLocalizations { @override String errorWritersideStartTopicDelete(String topic) { - return 'Startemnet $topic kan ikke slettes. Velg en annen startside først.'; + return 'Startsiden $topic kan ikke slettes. Velg en annen startside først.'; } @override @@ -4748,4 +4765,30 @@ class AppLocalizationsNb extends AppLocalizations { @override String get nextcloudChooseConflictingAttributes => 'Velg hver motstridende egenskap.'; + + @override + String get nextcloudUseServerNote => 'Bruk dette servernotatet'; + + @override + String get nextcloudUseServerNoteExplanation => + 'Knytter dette utkastet til det valgte servernotatet og synkroniserer deretter beholdte lokale endringer. En samsvarende forespørsel bekrefter ikke hvem som opprettet notatet.'; + + @override + String get nextcloudExactCandidate => + 'Nøyaktig samsvar med forespørselen; identiteten er ubekreftet.'; + + @override + String get nextcloudPossibleCandidate => + 'Mulig samsvar etter normalisering; identiteten er ubekreftet.'; + + @override + String get nextcloudSelectCandidate => + 'Velg et servernotat som skal gjennomgås'; + + @override + String get nextcloudCreateSeparate => 'Opprett et separat notat'; + + @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.'; } diff --git a/lib/l10n/generated/app_localizations_nl.dart b/lib/l10n/generated/app_localizations_nl.dart index d3d3e856..46021aec 100644 --- a/lib/l10n/generated/app_localizations_nl.dart +++ b/lib/l10n/generated/app_localizations_nl.dart @@ -10,6 +10,9 @@ import 'app_localizations.dart'; class AppLocalizationsNl extends AppLocalizations { AppLocalizationsNl([String locale = 'nl']) : super(locale); + @override + String get closeOtherTabs => 'Andere tabbladen sluiten'; + @override String get tocTopicFromTemplate => 'Onderwerp uit sjabloon...'; @@ -1535,9 +1538,23 @@ class AppLocalizationsNl extends AppLocalizations { return '$_temp0'; } + @override + String get topicIsHomePageRemovalWarning => + 'Dit onderwerp is de startpagina van deze instantie. Kies een andere startpagina voordat u het uit de inhoudsopgave verwijdert.'; + + @override + String get newHomePage => 'Nieuwe startpagina'; + + @override + String get chooseHomePage => 'Startpagina kiezen'; + + @override + String get noHomePageReplacement => + 'Voeg een ander geschikt onderwerp toe aan deze instantie of koppel het voordat u de startpagina verwijdert.'; + @override String get topicIsStartPageRemovalWarning => - 'Dit onderwerp wordt gebruikt als startpagina van een instantie. Controleer het gebruik ervan en wijs een andere startpagina toe voordat u verdergaat.'; + 'Dit onderwerp is de startpagina van een instantie. Stel een andere startpagina in voordat u het bronbestand verwijdert.'; @override String topicUsagesCount(int count) { @@ -1556,7 +1573,7 @@ class AppLocalizationsNl extends AppLocalizations { String get topicUsageTocElements => 'TOC-elementen'; @override - String get topicUsageStartPages => 'Startpagina\'s'; + String get topicUsageStartPages => 'Startpagina’s'; @override String get topicUsageTopicLinks => 'Onderwerplinks'; @@ -2184,7 +2201,7 @@ class AppLocalizationsNl extends AppLocalizations { @override String errorWritersideStartTopicDelete(String topic) { - return 'Het startonderwerp $topic kan niet worden verwijderd. Kies eerst een andere startpagina.'; + return 'De startpagina $topic kan niet worden verwijderd. Kies eerst een andere startpagina.'; } @override @@ -4793,4 +4810,30 @@ class AppLocalizationsNl extends AppLocalizations { @override String get nextcloudChooseConflictingAttributes => 'Kies voor elk conflicterend kenmerk een waarde.'; + + @override + String get nextcloudUseServerNote => 'Deze servernotitie gebruiken'; + + @override + String get nextcloudUseServerNoteExplanation => + 'Koppelt dit concept aan de geselecteerde servernotitie en synchroniseert daarna de behouden lokale wijzigingen. Een overeenkomend verzoek bevestigt niet wie de notitie heeft gemaakt.'; + + @override + String get nextcloudExactCandidate => + 'Exacte overeenkomst met het verzoek; identiteit onbevestigd.'; + + @override + String get nextcloudPossibleCandidate => + 'Mogelijke overeenkomst na normalisatie; identiteit onbevestigd.'; + + @override + String get nextcloudSelectCandidate => + 'Selecteer een servernotitie om te bekijken'; + + @override + String get nextcloudCreateSeparate => 'Een afzonderlijke notitie maken'; + + @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.'; } diff --git a/lib/l10n/generated/app_localizations_pl.dart b/lib/l10n/generated/app_localizations_pl.dart index f7fd873c..04b550f9 100644 --- a/lib/l10n/generated/app_localizations_pl.dart +++ b/lib/l10n/generated/app_localizations_pl.dart @@ -10,6 +10,9 @@ import 'app_localizations.dart'; class AppLocalizationsPl extends AppLocalizations { AppLocalizationsPl([String locale = 'pl']) : super(locale); + @override + String get closeOtherTabs => 'Zamknij pozostałe karty'; + @override String get tocTopicFromTemplate => 'Temat z szablonu...'; @@ -1553,9 +1556,23 @@ class AppLocalizationsPl extends AppLocalizations { return '$_temp0'; } + @override + String get topicIsHomePageRemovalWarning => + 'Ten temat jest stroną główną tej instancji. Wybierz inną stronę główną przed usunięciem go ze spisu treści.'; + + @override + String get newHomePage => 'Nowa strona główna'; + + @override + String get chooseHomePage => 'Wybierz stronę główną'; + + @override + String get noHomePageReplacement => + 'Dodaj lub połącz inny odpowiedni temat z tą instancją przed usunięciem jej strony głównej.'; + @override String get topicIsStartPageRemovalWarning => - 'Ten temat jest używany jako strona początkowa instancji. Przejrzyj jego użycia i przypisz inną stronę początkową przed kontynuowaniem.'; + 'Ten temat jest stroną główną instancji. Ustaw inną stronę główną przed usunięciem jego pliku źródłowego.'; @override String topicUsagesCount(int count) { @@ -1574,7 +1591,7 @@ class AppLocalizationsPl extends AppLocalizations { String get topicUsageTocElements => 'Elementy spisu treści'; @override - String get topicUsageStartPages => 'Strony początkowe'; + String get topicUsageStartPages => 'Strony główne'; @override String get topicUsageTopicLinks => 'Linki do tematów'; @@ -2205,7 +2222,7 @@ class AppLocalizationsPl extends AppLocalizations { @override String errorWritersideStartTopicDelete(String topic) { - return 'Nie można usunąć tematu początkowego $topic. Najpierw wybierz inną stronę początkową.'; + return 'Nie można usunąć strony głównej $topic. Najpierw wybierz inną stronę główną.'; } @override @@ -4801,4 +4818,30 @@ class AppLocalizationsPl extends AppLocalizations { @override String get nextcloudChooseConflictingAttributes => 'Wybierz wartość każdego sprzecznego atrybutu.'; + + @override + String get nextcloudUseServerNote => 'Użyj tej notatki z serwera'; + + @override + String get nextcloudUseServerNoteExplanation => + 'Łączy ten szkic z wybraną notatką na serwerze, a następnie synchronizuje zachowane zmiany lokalne. Zgodne żądanie nie potwierdza, kto utworzył notatkę.'; + + @override + String get nextcloudExactCandidate => + 'Dokładna zgodność z żądaniem; tożsamość niepotwierdzona.'; + + @override + String get nextcloudPossibleCandidate => + 'Możliwa zgodność po normalizacji żądania; tożsamość niepotwierdzona.'; + + @override + String get nextcloudSelectCandidate => + 'Wybierz notatkę z serwera do sprawdzenia'; + + @override + String get nextcloudCreateSeparate => 'Utwórz osobną notatkę'; + + @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.'; } diff --git a/lib/l10n/generated/app_localizations_pt.dart b/lib/l10n/generated/app_localizations_pt.dart index 9bfada37..96acc585 100644 --- a/lib/l10n/generated/app_localizations_pt.dart +++ b/lib/l10n/generated/app_localizations_pt.dart @@ -10,6 +10,9 @@ import 'app_localizations.dart'; class AppLocalizationsPt extends AppLocalizations { AppLocalizationsPt([String locale = 'pt']) : super(locale); + @override + String get closeOtherTabs => 'Fechar os outros separadores'; + @override String get tocTopicFromTemplate => 'Tópico a partir de modelo...'; @@ -1540,9 +1543,23 @@ class AppLocalizationsPt extends AppLocalizations { return '$_temp0'; } + @override + String get topicIsHomePageRemovalWarning => + 'Este tópico é a página inicial desta instância. Escolha outra página inicial antes de o remover do índice.'; + + @override + String get newHomePage => 'Nova página inicial'; + + @override + String get chooseHomePage => 'Escolher página inicial'; + + @override + String get noHomePageReplacement => + 'Adicione ou associe outro tópico elegível a esta instância antes de remover a sua página inicial.'; + @override String get topicIsStartPageRemovalWarning => - 'Este tópico é usado como página inicial de uma instância. Reveja os usos dele e atribua outra página inicial antes de continuar.'; + 'Este tópico é a página inicial de uma instância. Defina outra página inicial antes de eliminar o seu ficheiro de origem.'; @override String topicUsagesCount(int count) { @@ -2184,7 +2201,7 @@ class AppLocalizationsPt extends AppLocalizations { @override String errorWritersideStartTopicDelete(String topic) { - return 'O tópico inicial $topic não pode ser eliminado. Escolha primeiro outra página inicial.'; + return 'Não é possível eliminar a página inicial $topic. Escolha primeiro outra página inicial.'; } @override @@ -4808,12 +4825,41 @@ class AppLocalizationsPt extends AppLocalizations { @override String get nextcloudChooseConflictingAttributes => 'Escolha cada atributo em conflito.'; + + @override + String get nextcloudUseServerNote => 'Usar esta nota do servidor'; + + @override + String get nextcloudUseServerNoteExplanation => + 'Associa este rascunho à nota selecionada no servidor e depois sincroniza as alterações locais preservadas. Um pedido correspondente não confirma quem criou a nota.'; + + @override + String get nextcloudExactCandidate => + 'Correspondência exata com o pedido; identidade não confirmada.'; + + @override + String get nextcloudPossibleCandidate => + 'Possível correspondência após normalização; identidade não confirmada.'; + + @override + String get nextcloudSelectCandidate => + 'Selecionar uma nota do servidor para rever'; + + @override + String get nextcloudCreateSeparate => 'Criar uma nota separada'; + + @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.'; } /// The translations for Portuguese, as used in Brazil (`pt_BR`). class AppLocalizationsPtBr extends AppLocalizationsPt { AppLocalizationsPtBr() : super('pt_BR'); + @override + String get closeOtherTabs => 'Fechar as outras abas'; + @override String get tocTopicFromTemplate => 'Tópico a partir de modelo...'; @@ -6345,9 +6391,23 @@ class AppLocalizationsPtBr extends AppLocalizationsPt { return '$_temp0'; } + @override + String get topicIsHomePageRemovalWarning => + 'Este tópico é a página inicial desta instância. Escolha outra página inicial antes de removê-lo do sumário.'; + + @override + String get newHomePage => 'Nova página inicial'; + + @override + String get chooseHomePage => 'Escolher página inicial'; + + @override + String get noHomePageReplacement => + 'Adicione ou vincule outro tópico elegível a esta instância antes de remover sua página inicial.'; + @override String get topicIsStartPageRemovalWarning => - 'Este tópico é usado como página inicial de uma instância. Revise os usos dele e atribua outra página inicial antes de continuar.'; + 'Este tópico é a página inicial de uma instância. Defina outra página inicial antes de excluir seu arquivo de origem.'; @override String topicUsagesCount(int count) { @@ -6989,7 +7049,7 @@ class AppLocalizationsPtBr extends AppLocalizationsPt { @override String errorWritersideStartTopicDelete(String topic) { - return 'O tópico inicial $topic não pode ser excluído. Escolha primeiro outra página inicial.'; + return 'Não é possível excluir a página inicial $topic. Escolha outra página inicial primeiro.'; } @override @@ -9610,4 +9670,30 @@ class AppLocalizationsPtBr extends AppLocalizationsPt { @override String get nextcloudChooseConflictingAttributes => 'Escolha cada atributo em conflito.'; + + @override + String get nextcloudUseServerNote => 'Usar esta nota do servidor'; + + @override + String get nextcloudUseServerNoteExplanation => + 'Vincula este rascunho à nota selecionada no servidor e depois sincroniza as alterações locais preservadas. Uma solicitação correspondente não confirma quem criou a nota.'; + + @override + String get nextcloudExactCandidate => + 'Correspondência exata com a solicitação; identidade não confirmada.'; + + @override + String get nextcloudPossibleCandidate => + 'Possível correspondência após normalização; identidade não confirmada.'; + + @override + String get nextcloudSelectCandidate => + 'Selecionar uma nota do servidor para revisar'; + + @override + String get nextcloudCreateSeparate => 'Criar uma nota separada'; + + @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.'; } diff --git a/lib/l10n/generated/app_localizations_ru.dart b/lib/l10n/generated/app_localizations_ru.dart index 75ec669a..68d792a0 100644 --- a/lib/l10n/generated/app_localizations_ru.dart +++ b/lib/l10n/generated/app_localizations_ru.dart @@ -10,6 +10,9 @@ import 'app_localizations.dart'; class AppLocalizationsRu extends AppLocalizations { AppLocalizationsRu([String locale = 'ru']) : super(locale); + @override + String get closeOtherTabs => 'Закрыть другие вкладки'; + @override String get tocTopicFromTemplate => 'Тема из шаблона...'; @@ -1549,9 +1552,23 @@ class AppLocalizationsRu extends AppLocalizations { return '$_temp0'; } + @override + String get topicIsHomePageRemovalWarning => + 'Эта тема является домашней страницей этого экземпляра. Выберите другую домашнюю страницу перед её удалением из оглавления.'; + + @override + String get newHomePage => 'Новая домашняя страница'; + + @override + String get chooseHomePage => 'Выбрать домашнюю страницу'; + + @override + String get noHomePageReplacement => + 'Добавьте или привяжите к этому экземпляру другую подходящую тему перед удалением его домашней страницы.'; + @override String get topicIsStartPageRemovalWarning => - 'Эта тема используется как стартовая страница экземпляра. Просмотрите её использования и назначьте другую стартовую страницу, прежде чем продолжить.'; + 'Эта тема является домашней страницей экземпляра. Назначьте другую домашнюю страницу перед удалением её исходного файла.'; @override String topicUsagesCount(int count) { @@ -1569,7 +1586,7 @@ class AppLocalizationsRu extends AppLocalizations { String get topicUsageTocElements => 'Элементы оглавления'; @override - String get topicUsageStartPages => 'Стартовые страницы'; + String get topicUsageStartPages => 'Домашние страницы'; @override String get topicUsageTopicLinks => 'Ссылки на темы'; @@ -2200,7 +2217,7 @@ class AppLocalizationsRu extends AppLocalizations { @override String errorWritersideStartTopicDelete(String topic) { - return 'Стартовую тему $topic нельзя удалить. Сначала выберите другую стартовую страницу.'; + return 'Домашнюю страницу $topic нельзя удалить. Сначала выберите другую домашнюю страницу.'; } @override @@ -4804,4 +4821,30 @@ class AppLocalizationsRu extends AppLocalizations { @override String get nextcloudChooseConflictingAttributes => 'Выберите значение каждого конфликтующего атрибута.'; + + @override + String get nextcloudUseServerNote => 'Использовать эту заметку на сервере'; + + @override + String get nextcloudUseServerNoteExplanation => + 'Связывает этот черновик с выбранной заметкой на сервере, затем синхронизирует сохранённые локальные изменения. Совпадение запроса не подтверждает, кто создал заметку.'; + + @override + String get nextcloudExactCandidate => + 'Точное совпадение с запросом; идентичность не подтверждена.'; + + @override + String get nextcloudPossibleCandidate => + 'Возможное совпадение после нормализации; идентичность не подтверждена.'; + + @override + String get nextcloudSelectCandidate => + 'Выберите заметку на сервере для проверки'; + + @override + String get nextcloudCreateSeparate => 'Создать отдельную заметку'; + + @override + String get nextcloudCreateSeparateWarning => + 'Первый запрос мог уже создать заметку на сервере. Будет создана отдельная заметка с сохранённой локальной работой. Предполагаемые дубликаты не будут удалены.'; } diff --git a/lib/l10n/generated/app_localizations_tr.dart b/lib/l10n/generated/app_localizations_tr.dart index e5da693d..a4ef4597 100644 --- a/lib/l10n/generated/app_localizations_tr.dart +++ b/lib/l10n/generated/app_localizations_tr.dart @@ -10,6 +10,9 @@ import 'app_localizations.dart'; class AppLocalizationsTr extends AppLocalizations { AppLocalizationsTr([String locale = 'tr']) : super(locale); + @override + String get closeOtherTabs => 'Diğer sekmeleri kapat'; + @override String get tocTopicFromTemplate => 'Şablondan Konu...'; @@ -1531,9 +1534,23 @@ class AppLocalizationsTr extends AppLocalizations { return '$_temp0'; } + @override + String get topicIsHomePageRemovalWarning => + 'Bu konu bu örneğin ana sayfasıdır. İçindekilerden kaldırmadan önce başka bir ana sayfa seçin.'; + + @override + String get newHomePage => 'Yeni ana sayfa'; + + @override + String get chooseHomePage => 'Ana sayfa seçin'; + + @override + String get noHomePageReplacement => + 'Ana sayfasını kaldırmadan önce bu örneğe başka bir uygun konu ekleyin veya bağlayın.'; + @override String get topicIsStartPageRemovalWarning => - 'Bu konu bir Writerside örneğinin başlangıç sayfası olarak kullanılıyor. Devam etmeden önce kullanımlarını inceleyin ve başka bir başlangıç sayfası atayın.'; + 'Bu konu bir örneğin ana sayfasıdır. Kaynak dosyasını silmeden önce başka bir ana sayfa atayın.'; @override String topicUsagesCount(int count) { @@ -1551,7 +1568,7 @@ class AppLocalizationsTr extends AppLocalizations { String get topicUsageTocElements => 'İçindekiler öğeleri'; @override - String get topicUsageStartPages => 'Başlangıç sayfaları'; + String get topicUsageStartPages => 'Ana sayfalar'; @override String get topicUsageTopicLinks => 'Konu bağlantıları'; @@ -2169,7 +2186,7 @@ class AppLocalizationsTr extends AppLocalizations { @override String errorWritersideStartTopicDelete(String topic) { - return '$topic başlangıç konusu silinemez. Önce başka bir başlangıç sayfası seçin.'; + return 'Ana sayfa $topic silinemez. Önce başka bir ana sayfa seçin.'; } @override @@ -4748,4 +4765,29 @@ class AppLocalizationsTr extends AppLocalizations { @override String get nextcloudChooseConflictingAttributes => 'Çakışan her öznitelik için bir değer seçin.'; + + @override + String get nextcloudUseServerNote => 'Bu sunucu notunu kullan'; + + @override + String get nextcloudUseServerNoteExplanation => + 'Bu taslağı seçilen sunucu notuna bağlar, ardından korunan yerel değişiklikleri eşitler. Eşleşen bir istek, notu kimin oluşturduğunu doğrulamaz.'; + + @override + String get nextcloudExactCandidate => + 'İstekle tam eşleşme; kimlik doğrulanmadı.'; + + @override + String get nextcloudPossibleCandidate => + 'Düzenlenmiş istekle olası eşleşme; kimlik doğrulanmadı.'; + + @override + String get nextcloudSelectCandidate => 'İncelemek için bir sunucu notu seçin'; + + @override + String get nextcloudCreateSeparate => 'Ayrı bir not oluştur'; + + @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.'; } diff --git a/lib/l10n/generated/app_localizations_uk.dart b/lib/l10n/generated/app_localizations_uk.dart index 21b80834..830590c4 100644 --- a/lib/l10n/generated/app_localizations_uk.dart +++ b/lib/l10n/generated/app_localizations_uk.dart @@ -10,6 +10,9 @@ import 'app_localizations.dart'; class AppLocalizationsUk extends AppLocalizations { AppLocalizationsUk([String locale = 'uk']) : super(locale); + @override + String get closeOtherTabs => 'Закрити інші вкладки'; + @override String get tocTopicFromTemplate => 'Тема з шаблону...'; @@ -1556,9 +1559,23 @@ class AppLocalizationsUk extends AppLocalizations { return '$_temp0'; } + @override + String get topicIsHomePageRemovalWarning => + 'Ця тема є домашньою сторінкою цього екземпляра. Виберіть іншу домашню сторінку, перш ніж видалити її зі змісту.'; + + @override + String get newHomePage => 'Нова домашня сторінка'; + + @override + String get chooseHomePage => 'Вибрати домашню сторінку'; + + @override + String get noHomePageReplacement => + 'Додайте або прив’яжіть іншу придатну тему до цього екземпляра перед видаленням його домашньої сторінки.'; + @override String get topicIsStartPageRemovalWarning => - 'Ця тема використовується як початкова сторінка екземпляра. Перегляньте її використання та призначте іншу початкову сторінку, перш ніж продовжити.'; + 'Ця тема є домашньою сторінкою екземпляра. Призначте іншу домашню сторінку перед видаленням її вихідного файлу.'; @override String topicUsagesCount(int count) { @@ -1577,7 +1594,7 @@ class AppLocalizationsUk extends AppLocalizations { String get topicUsageTocElements => 'Елементи змісту'; @override - String get topicUsageStartPages => 'Початкові сторінки'; + String get topicUsageStartPages => 'Домашні сторінки'; @override String get topicUsageTopicLinks => 'Посилання на теми'; @@ -2209,7 +2226,7 @@ class AppLocalizationsUk extends AppLocalizations { @override String errorWritersideStartTopicDelete(String topic) { - return 'Початкову тему $topic не можна видалити. Спочатку виберіть іншу початкову сторінку.'; + return 'Домашню сторінку $topic не можна видалити. Спочатку виберіть іншу домашню сторінку.'; } @override @@ -4814,4 +4831,30 @@ class AppLocalizationsUk extends AppLocalizations { @override String get nextcloudChooseConflictingAttributes => 'Виберіть значення кожного конфліктного атрибута.'; + + @override + String get nextcloudUseServerNote => 'Використати цю нотатку на сервері'; + + @override + String get nextcloudUseServerNoteExplanation => + 'Пов’язує цю чернетку з вибраною нотаткою на сервері, а потім синхронізує збережені локальні зміни. Збіг запиту не підтверджує, хто створив нотатку.'; + + @override + String get nextcloudExactCandidate => + 'Точний збіг із запитом; тотожність не підтверджено.'; + + @override + String get nextcloudPossibleCandidate => + 'Можливий збіг після нормалізації; тотожність не підтверджено.'; + + @override + String get nextcloudSelectCandidate => + 'Виберіть нотатку на сервері для перегляду'; + + @override + String get nextcloudCreateSeparate => 'Створити окрему нотатку'; + + @override + String get nextcloudCreateSeparateWarning => + 'Перший запит міг уже створити нотатку на сервері. Буде створено окрему нотатку зі збереженою локальною роботою. Ймовірні дублікати не буде видалено.'; } diff --git a/lib/l10n/generated/app_localizations_vi.dart b/lib/l10n/generated/app_localizations_vi.dart index 3869078a..7fbddeeb 100644 --- a/lib/l10n/generated/app_localizations_vi.dart +++ b/lib/l10n/generated/app_localizations_vi.dart @@ -10,6 +10,9 @@ import 'app_localizations.dart'; class AppLocalizationsVi extends AppLocalizations { AppLocalizationsVi([String locale = 'vi']) : super(locale); + @override + String get closeOtherTabs => 'Đóng các thẻ khác'; + @override String get tocTopicFromTemplate => 'Chủ đề từ mẫu...'; @@ -1524,9 +1527,23 @@ class AppLocalizationsVi extends AppLocalizations { return '$_temp0'; } + @override + String get topicIsHomePageRemovalWarning => + 'Chủ đề này là trang chủ của thực thể này. Chọn trang chủ khác trước khi xóa chủ đề khỏi mục lục.'; + + @override + String get newHomePage => 'Trang chủ mới'; + + @override + String get chooseHomePage => 'Chọn trang chủ'; + + @override + String get noHomePageReplacement => + 'Thêm hoặc liên kết một chủ đề hợp lệ khác vào thực thể này trước khi xóa trang chủ của nó.'; + @override String get topicIsStartPageRemovalWarning => - 'Chủ đề này đang được dùng làm trang bắt đầu của một thực thể. Hãy xem lại cách sử dụng và chỉ định trang bắt đầu khác trước khi tiếp tục.'; + 'Chủ đề này là trang chủ của một thực thể. Chỉ định trang chủ khác trước khi xóa tệp nguồn của chủ đề.'; @override String topicUsagesCount(int count) { @@ -1545,7 +1562,7 @@ class AppLocalizationsVi extends AppLocalizations { String get topicUsageTocElements => 'Phần tử mục lục'; @override - String get topicUsageStartPages => 'Trang bắt đầu'; + String get topicUsageStartPages => 'Các trang chủ'; @override String get topicUsageTopicLinks => 'Liên kết chủ đề'; @@ -2165,7 +2182,7 @@ class AppLocalizationsVi extends AppLocalizations { @override String errorWritersideStartTopicDelete(String topic) { - return 'Không thể xóa chủ đề bắt đầu $topic. Hãy chọn một trang bắt đầu khác trước.'; + return 'Không thể xóa trang chủ $topic. Hãy chọn trang chủ khác trước.'; } @override @@ -4738,4 +4755,29 @@ class AppLocalizationsVi extends AppLocalizations { @override String get nextcloudChooseConflictingAttributes => 'Chọn giá trị cho từng thuộc tính xung đột.'; + + @override + String get nextcloudUseServerNote => 'Dùng ghi chú trên máy chủ này'; + + @override + String get nextcloudUseServerNoteExplanation => + 'Liên kết bản nháp này với ghi chú đã chọn trên máy chủ, rồi đồng bộ các thay đổi cục bộ được giữ lại. Yêu cầu trùng khớp không xác nhận ai đã tạo ghi chú.'; + + @override + String get nextcloudExactCandidate => + 'Khớp chính xác với yêu cầu; chưa xác nhận danh tính.'; + + @override + String get nextcloudPossibleCandidate => + 'Có thể khớp sau khi chuẩn hóa yêu cầu; chưa xác nhận danh tính.'; + + @override + String get nextcloudSelectCandidate => 'Chọn ghi chú trên máy chủ để xem xét'; + + @override + String get nextcloudCreateSeparate => 'Tạo ghi chú riêng'; + + @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.'; } diff --git a/lib/l10n/generated/app_localizations_zh.dart b/lib/l10n/generated/app_localizations_zh.dart index 600dcb2e..005f0425 100644 --- a/lib/l10n/generated/app_localizations_zh.dart +++ b/lib/l10n/generated/app_localizations_zh.dart @@ -10,6 +10,9 @@ import 'app_localizations.dart'; class AppLocalizationsZh extends AppLocalizations { AppLocalizationsZh([String locale = 'zh']) : super(locale); + @override + String get closeOtherTabs => '关闭其他标签页'; + @override String get tocTopicFromTemplate => '从模板创建主题...'; @@ -1466,8 +1469,19 @@ class AppLocalizationsZh extends AppLocalizations { } @override - String get topicIsStartPageRemovalWarning => - '此主题被用作实例的起始页。请查看其使用情况,并在继续前指定其他起始页。'; + String get topicIsHomePageRemovalWarning => '此主题是此实例的主页。从目录中移除它之前,请选择另一个主页。'; + + @override + String get newHomePage => '新主页'; + + @override + String get chooseHomePage => '选择主页'; + + @override + String get noHomePageReplacement => '移除主页之前,请向此实例添加或链接另一个符合条件的主题。'; + + @override + String get topicIsStartPageRemovalWarning => '此主题是某个实例的主页。删除其源文件之前,请指定另一个主页。'; @override String topicUsagesCount(int count) { @@ -1484,7 +1498,7 @@ class AppLocalizationsZh extends AppLocalizations { String get topicUsageTocElements => '目录元素'; @override - String get topicUsageStartPages => '起始页'; + String get topicUsageStartPages => '主页'; @override String get topicUsageTopicLinks => '主题链接'; @@ -2069,7 +2083,7 @@ class AppLocalizationsZh extends AppLocalizations { @override String errorWritersideStartTopicDelete(String topic) { - return '无法删除起始主题 $topic。请先选择其他起始页。'; + return '无法删除主页 $topic。请先选择另一个主页。'; } @override @@ -4540,12 +4554,38 @@ class AppLocalizationsZh extends AppLocalizations { @override String get nextcloudChooseConflictingAttributes => '为每个冲突属性选择一个值。'; + + @override + String get nextcloudUseServerNote => '使用此服务器笔记'; + + @override + String get nextcloudUseServerNoteExplanation => + '将此草稿关联到选定的服务器笔记,然后同步保留的本地更改。请求匹配并不能确认笔记由谁创建。'; + + @override + String get nextcloudExactCandidate => '与请求完全匹配;身份尚未确认。'; + + @override + String get nextcloudPossibleCandidate => '可能与规范化后的请求匹配;身份尚未确认。'; + + @override + String get nextcloudSelectCandidate => '选择要检查的服务器笔记'; + + @override + String get nextcloudCreateSeparate => '创建独立笔记'; + + @override + String get nextcloudCreateSeparateWarning => + '首次请求可能已在服务器上创建笔记。此操作将使用保留的本地工作创建独立笔记。不会删除任何疑似重复的笔记。'; } /// The translations for Chinese, as used in China (`zh_CN`). class AppLocalizationsZhCn extends AppLocalizationsZh { AppLocalizationsZhCn() : super('zh_CN'); + @override + String get closeOtherTabs => '关闭其他标签页'; + @override String get tocTopicFromTemplate => '从模板创建主题...'; @@ -6002,8 +6042,19 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { } @override - String get topicIsStartPageRemovalWarning => - '此主题被用作实例的起始页。请查看其使用情况,并在继续前指定其他起始页。'; + String get topicIsHomePageRemovalWarning => '此主题是此实例的主页。从目录中移除它之前,请选择另一个主页。'; + + @override + String get newHomePage => '新主页'; + + @override + String get chooseHomePage => '选择主页'; + + @override + String get noHomePageReplacement => '移除主页之前,请向此实例添加或链接另一个符合条件的主题。'; + + @override + String get topicIsStartPageRemovalWarning => '此主题是某个实例的主页。删除其源文件之前,请指定另一个主页。'; @override String topicUsagesCount(int count) { @@ -6020,7 +6071,7 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { String get topicUsageTocElements => '目录元素'; @override - String get topicUsageStartPages => '起始页'; + String get topicUsageStartPages => '主页'; @override String get topicUsageTopicLinks => '主题链接'; @@ -6605,7 +6656,7 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { @override String errorWritersideStartTopicDelete(String topic) { - return '无法删除起始主题 $topic。请先选择其他起始页。'; + return '无法删除主页 $topic。请先选择另一个主页。'; } @override @@ -9076,4 +9127,27 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { @override String get nextcloudChooseConflictingAttributes => '为每个冲突属性选择一个值。'; + + @override + String get nextcloudUseServerNote => '使用此服务器笔记'; + + @override + String get nextcloudUseServerNoteExplanation => + '将此草稿关联到选定的服务器笔记,然后同步保留的本地更改。请求匹配并不能确认笔记由谁创建。'; + + @override + String get nextcloudExactCandidate => '与请求完全匹配;身份尚未确认。'; + + @override + String get nextcloudPossibleCandidate => '可能与规范化后的请求匹配;身份尚未确认。'; + + @override + String get nextcloudSelectCandidate => '选择要检查的服务器笔记'; + + @override + String get nextcloudCreateSeparate => '创建独立笔记'; + + @override + String get nextcloudCreateSeparateWarning => + '首次请求可能已在服务器上创建笔记。此操作将使用保留的本地工作创建独立笔记。不会删除任何疑似重复的笔记。'; } diff --git a/lib/src/app/app_metadata.dart b/lib/src/app/app_metadata.dart index 86f05517..ac974a9b 100644 --- a/lib/src/app/app_metadata.dart +++ b/lib/src/app/app_metadata.dart @@ -1 +1 @@ -const busyMarkAppVersion = '0.6.0'; +const busyMarkAppVersion = '0.6.1'; diff --git a/lib/src/app/busymark_app.dart b/lib/src/app/busymark_app.dart index 7bc3bd5a..59fcd1d2 100644 --- a/lib/src/app/busymark_app.dart +++ b/lib/src/app/busymark_app.dart @@ -22,6 +22,7 @@ import '../workspace/workspace_model.dart'; import '../workspace/presentation/welcome_screen.dart'; import '../workspace/workspace_safety.dart'; import '../workspace/workspace_tabs.dart'; +import '../workspace/workspace_tab_actions.dart'; import 'app_router.dart'; import 'app_locale.dart'; import 'app_settings.dart'; @@ -728,44 +729,16 @@ class BusyMarkApp extends ConsumerWidget { return; } final activeTab = tabs[activeIndex]; - if (activeTab.kind == WorkspaceTabKind.file) { - if (activeTab.dirty && - (!await confirmSafeToCloseActiveDocument(context, ref) || - !context.mounted)) { - return; - } - } - await _closeWorkspaceTab(ref, activeTab); + await closeWorkspaceTab(context, ref, workspace: workspace, tab: activeTab); } Future _closeAllOpenFileTabs( BuildContext context, WidgetRef ref, ) async { - final workspaceState = ref.read(workspaceControllerProvider); - final workspace = workspaceState.workspace; - final gitState = ref.read(gitControllerProvider); - if (workspace == null || - workspaceTabEntries( - workspace: workspace, - gitState: gitState, - documentBuffers: workspaceState.documentBuffers, - activeBufferId: workspaceState.activeBufferId, - localHistoryRevisionId: ref - .read(localHistoryControllerProvider) - .selectedRevisionId, - localHistoryDocumentName: ref - .read(localHistoryControllerProvider) - .selectedDocument - ?.displayName, - ).isEmpty) { - return; - } - if (!await confirmSafeToContinue(context, ref) || !context.mounted) { - return; - } - await ref.read(workspaceControllerProvider.notifier).closeAllOpenFileTabs(); - ref.read(gitControllerProvider.notifier).clearSelection(); + final workspace = ref.read(workspaceControllerProvider).workspace; + if (workspace == null) return; + await closeAllWorkspaceTabs(context, ref, workspace: workspace); } Future _activateWorkspaceTab( @@ -791,25 +764,6 @@ class BusyMarkApp extends ConsumerWidget { } } - Future _closeWorkspaceTab(WidgetRef ref, WorkspaceTabEntry tab) async { - final gitController = ref.read(gitControllerProvider.notifier); - switch (tab.kind) { - case WorkspaceTabKind.file: - await ref - .read(workspaceControllerProvider.notifier) - .closeDocumentBuffer(tab.bufferId!); - gitController.deactivateDiffFile(); - case WorkspaceTabKind.gitDiff: - if (tab.path.isEmpty) { - gitController.clearSelection(); - } else { - gitController.closeDiffFile(tab.path); - } - case WorkspaceTabKind.localHistory: - ref.read(localHistoryControllerProvider.notifier).clearComparison(); - } - } - Future _chooseWorkspaceFolder(WidgetRef ref) async { final context = rootNavigatorKey.currentContext; if (context == null) { diff --git a/lib/src/editor/wysiwyg/wysiwyg_clipboard_html.dart b/lib/src/editor/wysiwyg/wysiwyg_clipboard_html.dart index 12595636..596e3850 100644 --- a/lib/src/editor/wysiwyg/wysiwyg_clipboard_html.dart +++ b/lib/src/editor/wysiwyg/wysiwyg_clipboard_html.dart @@ -39,18 +39,21 @@ class WysiwygClipboardHtml { final content = start >= 0 && end > start ? source.substring(start + ''.length, end) : source; - final body = html.parse(content).body; - if (body == null) return null; + // Fragment parsing retains leading text that document parsing consumes + // before an inferred . Full clipboard documents are accepted too. + final body = html.parseFragment(content); final normalizer = _HtmlNormalizer(); final clean = dom.Element.tag('div') ..nodes.addAll(normalizer.nodes(body.nodes, 0, const {})); var id = 0; - final parsed = const RawHtmlAdapter().parseRawHtmlBlock( - clean.outerHtml, - () => 'clipboard-html-${id++}', - ); + final parsed = const RawHtmlAdapter( + preserveInlineWhitespace: true, + ).parseRawHtmlBlock(clean.outerHtml, () => 'clipboard-html-${id++}'); if (parsed == null || !parsed.safe || parsed.blocks.isEmpty) return null; BusyBlock task(BusyBlock block) { + block = block.copyWith( + inlines: busyMarkWysiwygNormalizeClipboardFormatting(block.inlines), + ); final children = [for (final child in block.children) task(child)]; if ((block.kind == BusyBlockKind.unorderedListItem || block.kind == BusyBlockKind.orderedListItem) && @@ -77,7 +80,16 @@ class WysiwygClipboardHtml { dirty: true, ); } - return block.copyWith(children: children, dirty: true); + return block.copyWith( + children: children, + attributes: { + ...block.attributes, + if (block.plainText.isNotEmpty && + block.plainText.trimRight() != block.plainText) + busyMarkPreserveTextWhitespaceAttribute: 'true', + }, + dirty: true, + ); } return WysiwygClipboardFragment( @@ -328,6 +340,7 @@ class _HtmlNormalizer { 'script', 'style', 'head', + 'title', 'meta', 'link', 'iframe', @@ -347,27 +360,44 @@ class _HtmlNormalizer { throw const FormatException('Clipboard HTML nesting limit'); } final out = []; - for (final node in input) { + final siblings = input.toList(); + for (var index = 0; index < siblings.length; index++) { + final node = siblings[index]; if (++count > maxRawHtmlNodes) { throw const FormatException('Clipboard HTML node limit'); } if (node is dom.Text) { - final content = node.data.trim(); - if (content.isEmpty || styles.isEmpty) { - out.add(dom.Text(node.data)); - continue; + bool structural(dom.Node? sibling) => + sibling is dom.Element && + isSafeBlockHtmlTag(sibling.localName ?? ''); + final before = index > 0 ? siblings[index - 1] : null; + final after = index + 1 < siblings.length ? siblings[index + 1] : null; + var data = node.data; + final leading = data.substring(0, data.length - data.trimLeft().length); + final trailing = data.substring(data.trimRight().length); + if ((before == null || structural(before)) && + RegExp(r'[\r\n]').hasMatch(leading)) { + data = data.trimLeft(); + } + if ((after == null || structural(after)) && + RegExp(r'[\r\n]').hasMatch(trailing)) { + data = data.trimRight(); + } + if (data.trim().isEmpty) { + // Indentation between blocks is layout. Newlines at a container's + // edges are also layout; explicit horizontal inline spaces are text. + if (structural(before) || + structural(after) || + ((before == null || after == null) && + RegExp(r'[\r\n]').hasMatch(node.data))) { + continue; + } } - // Markdown emphasis cannot open/close against whitespace. Keep that - // whitespace as text outside the formatting, not inside delimiters. - final start = node.data.length - node.data.trimLeft().length; - final end = node.data.trimRight().length; - if (start > 0) out.add(dom.Text(node.data.substring(0, start))); - dom.Node text = dom.Text(content); + dom.Node text = dom.Text(data); for (final tag in _formattingTags.where(styles.contains)) { text = _element(tag, [text]); } out.add(text); - if (end < node.data.length) out.add(dom.Text(node.data.substring(end))); continue; } if (node is! dom.Element) continue; diff --git a/lib/src/editor/wysiwyg/wysiwyg_document_controller.dart b/lib/src/editor/wysiwyg/wysiwyg_document_controller.dart index 61ec123c..177ef9e0 100644 --- a/lib/src/editor/wysiwyg/wysiwyg_document_controller.dart +++ b/lib/src/editor/wysiwyg/wysiwyg_document_controller.dart @@ -522,6 +522,7 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { String text, { Iterable activeInlineKinds = const [], bool preserveTextWhitespace = false, + bool normalizePastedFormatting = false, }) { _replaceBlock( blockId, @@ -530,6 +531,7 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { text, activeInlineKinds: activeInlineKinds, preserveTextWhitespace: preserveTextWhitespace, + normalizePastedFormatting: normalizePastedFormatting, ), ); } @@ -2159,13 +2161,19 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { clipboardBlock.inlines, _inlineContextForReplacement(block.inlines, start, end), ); - final mergedInlines = _refreshBareUrlLinks( - _mergeAdjacentInlineStyles([ - ...partition.before, - ...insertedInlines, - ...partition.after, - ]), - ); + var mergedInlines = _mergeAdjacentInlineStyles([ + ...partition.before, + ...insertedInlines, + ...partition.after, + ]); + if (!ownsDestinationStructure) { + mergedInlines = _normalizePastedFormatting( + mergedInlines, + start, + start + inserted.text.length, + ); + } + mergedInlines = _refreshBareUrlLinks(mergedInlines); if (ownsDestinationStructure) { final structural = _styledBlockToBusyBlock(inserted, rootId: block.id); final retainsDescendants = _isBlockContentContainer(structural.kind); @@ -2188,7 +2196,12 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { // select its descendants. Keep the destination structure and only // replace its inline content. kind: block.kind, - attributes: block.attributes, + attributes: _attributesForText( + block.attributes, + block.kind, + beforeText + inserted.text + afterText, + preserveTextWhitespace: true, + ), inlines: mergedInlines, preserveRaw: false, dirty: true, @@ -2987,6 +3000,17 @@ BusyBlock busyMarkWysiwygClipboardBlock(BusyWysiwygStyledBlock styled) { ); } +/// HTML decoding uses the same run boundaries as insertion, after all inline +/// wrappers are available. Trimming individual HTML text leaves would turn +/// internal spaces in nested formatting into unformatted boundary spaces. +List busyMarkWysiwygNormalizeClipboardFormatting( + List inlines, +) => _normalizePastedFormatting( + _mergeAdjacentInlineStyles(inlines), + 0, + inlines.fold(0, (length, inline) => length + inline.plainText.length), +); + List busyMarkWysiwygClipboardInlineSlice( List inlines, int start, @@ -3409,6 +3433,11 @@ List _applyInlineReplacementContext( busyMarkSameInlineSemantics(wrapper, result.single)) { continue; } + if (_isMarkdownEmphasis(wrapper.kind)) { + // The inherited wrapper already supplies this style, including through + // differently styled children. Do not nest an incoming copy inside it. + result = _withoutInlineContext(result, wrapper); + } result = [ wrapper.copyWith( text: result.map((inline) => inline.plainText).join(), @@ -3419,6 +3448,124 @@ List _applyInlineReplacementContext( return result; } +List _withoutInlineContext( + List inlines, + BusyInline wrapper, +) => [ + for (final inline in inlines) + if (inline.children.isEmpty) + inline + else if (busyMarkSameInlineSemantics(inline, wrapper)) + ..._withoutInlineContext(inline.children, wrapper) + else + inline.copyWith( + children: _withoutInlineContext(inline.children, wrapper), + ), +]; + +bool _isMarkdownEmphasis(BusyInlineKind kind) => + kind == BusyInlineKind.strong || kind == BusyInlineKind.emphasis; + +// Normalize only runs touched by paste, after merging the surviving and +// inserted content. Whitespace between surviving formatted characters stays +// inside the run; boundary whitespace remains text outside its delimiters. +List _normalizePastedFormatting( + List inlines, + int start, + int end, +) { + final text = inlines.map((inline) => inline.plainText).join(); + final ranges = busyInlineStyleRanges(inlines); + final runs = + <({BusyInlineKind kind, int start, int end, int first, int last})>[]; + for (final kind in [BusyInlineKind.strong, BusyInlineKind.emphasis]) { + final styled = ranges.where((range) => range.kind == kind).toList(); + for (var index = 0; index < styled.length; index++) { + final runStart = styled[index].start; + var runEnd = styled[index].end; + // A single visual run can cross link or other wrapper boundaries. + while (index + 1 < styled.length && styled[index + 1].start <= runEnd) { + index++; + if (styled[index].end > runEnd) runEnd = styled[index].end; + } + if (runStart >= end || runEnd <= start) continue; + var first = runStart; + var last = runEnd; + while (first < last && _formattingWhitespace.hasMatch(text[first])) { + first++; + } + while (last > first && _formattingWhitespace.hasMatch(text[last - 1])) { + last--; + } + runs.add(( + kind: kind, + start: runStart, + end: runEnd, + first: first, + last: last, + )); + } + } + + List normalize(List values, int offset) { + final result = []; + for (final inline in values) { + final inlineStart = offset; + final length = inline.plainText.length; + offset += length; + if (inline.children.isEmpty) { + result.add(inline); + continue; + } + final children = normalize(inline.children, inlineStart); + final run = runs + .where( + (run) => + run.kind == inline.kind && + run.start <= inlineStart && + run.end >= offset, + ) + .firstOrNull; + final first = run == null + ? 0 + : (run.first - inlineStart).clamp(0, length); + final last = run == null + ? length + : (run.last - inlineStart).clamp(first, length); + if (first == 0 && last == length) { + result.add(inline.copyWith(children: children)); + continue; + } + final leading = _partitionInlinesForReplacement( + children, + first, + first, + refreshUrls: false, + ); + final trailing = _partitionInlinesForReplacement( + leading.after, + last - first, + last - first, + refreshUrls: false, + ); + result.addAll([ + ...leading.before, + if (last > first) + inline.copyWith( + text: inline.plainText.substring(first, last), + children: trailing.before, + ), + ...trailing.after, + ]); + } + return _mergeAdjacentInlineStyles(result); + } + + return normalize(inlines, 0); +} + +final _formattingWhitespace = RegExp(r'\s', unicode: true); + bool _containsInlineKind(List inlines, BusyInlineKind kind) { for (final inline in inlines) { if (inline.kind == kind || _containsInlineKind(inline.children, kind)) { @@ -3973,6 +4120,7 @@ BusyBlock _blockWithEditedText( String nextText, { Iterable activeInlineKinds = const [], bool preserveTextWhitespace = false, + bool normalizePastedFormatting = false, }) { final oldText = block.plainText; final oldRanges = busyInlineStyleRanges(block.inlines); @@ -3992,13 +4140,23 @@ BusyBlock _blockWithEditedText( nextText, nextRanges, ); + var inlines = _restoreSemanticInlineAnchors( + originalInlines: block.inlines, + oldText: oldText, + newText: nextText, + rebuiltInlines: rebuiltInlines, + ); + if (normalizePastedFormatting) { + final prefix = _commonPrefixLength(oldText, nextText); + final suffix = _commonSuffixLength(oldText, nextText, prefix); + inlines = _normalizePastedFormatting( + inlines, + prefix, + nextText.length - suffix, + ); + } return block.copyWith( - inlines: _restoreSemanticInlineAnchors( - originalInlines: block.inlines, - oldText: oldText, - newText: nextText, - rebuiltInlines: rebuiltInlines, - ), + inlines: inlines, attributes: _attributesForText( block.attributes, block.kind, @@ -4652,12 +4810,51 @@ List _inlinesFromStyleRanges( List _mergeAdjacentInlineStyles(List inlines) { final merged = []; - for (final sourceInline in inlines) { - final inline = sourceInline.children.isEmpty + BusyInline? formatting(BusyInline? inline) { + if (inline?.kind == BusyInlineKind.link && inline!.children.length == 1) { + inline = inline.children.single; + } + return inline?.kind == BusyInlineKind.strong || + inline?.kind == BusyInlineKind.emphasis + ? inline + : null; + } + + for (var index = 0; index < inlines.length; index++) { + final sourceInline = inlines[index]; + var inline = sourceInline.children.isEmpty ? sourceInline : sourceInline.copyWith( children: _mergeAdjacentInlineStyles(sourceInline.children), ); + if (inline.kind == BusyInlineKind.link) { + final before = formatting(merged.lastOrNull); + final after = formatting( + index + 1 < inlines.length ? inlines[index + 1] : null, + ); + var children = inline.children; + while (children.length == 1 && + _isMarkdownEmphasis(children.single.kind)) { + final wrapper = children.single; + if ((before == null || !_canMergeInlineStyles(before, wrapper)) && + (after == null || !_canMergeInlineStyles(wrapper, after))) { + children = wrapper.children; + continue; + } + // A fully styled label belongs to the adjoining visual run. Lift its + // wrapper so merging can enclose the intact link in one delimiter pair, + // including whitespace internal to that run (e.g. "hello [world]"). + inline = wrapper.copyWith( + text: inline.plainText, + children: [ + inline.copyWith( + children: _withoutInlineContext(inline.children, wrapper), + ), + ], + ); + break; + } + } if (merged.isEmpty || !_canMergeInlineStyles(merged.last, inline)) { merged.add(inline); continue; diff --git a/lib/src/editor/wysiwyg/wysiwyg_editor.dart b/lib/src/editor/wysiwyg/wysiwyg_editor.dart index e3894f25..5b81f6d4 100644 --- a/lib/src/editor/wysiwyg/wysiwyg_editor.dart +++ b/lib/src/editor/wysiwyg/wysiwyg_editor.dart @@ -1964,6 +1964,7 @@ class BusyMarkWysiwygEditorState extends State { value, activeInlineKinds: pendingInlineKinds, preserveTextWhitespace: origin == _WysiwygTextEditOrigin.paste, + normalizePastedFormatting: origin == _WysiwygTextEditOrigin.paste, ); if (value.isNotEmpty) { _pendingInlineKindsByBlockId.remove(blockId); diff --git a/lib/src/local_history/local_history_controller.dart b/lib/src/local_history/local_history_controller.dart index 0832fa7d..2e4fc7e8 100644 --- a/lib/src/local_history/local_history_controller.dart +++ b/lib/src/local_history/local_history_controller.dart @@ -1041,7 +1041,11 @@ class LocalHistoryController extends Notifier { final existingSourceId = _documentIdsByBuffer[source.bufferId]; // Save As freezes and retires any stale destination owner under the // shared store lock before writing the new file. - if (!await _enqueue(_pathRemapOwner, _settlePendingPathOperations)) { + if (!await _settlePromotionPathDependencies( + source.bufferId, + existingSourceId, + destinationPath, + )) { if (ref.mounted && existingSourceId != null && _documentIdsByBuffer[source.bufferId] == existingSourceId && @@ -2291,13 +2295,14 @@ class LocalHistoryController extends Notifier { } } if (_pendingPathOperations.isEmpty) return true; - if (!_pendingPathOperations.first.committed) { - _pendingPathOperations.first + for (final operation in _pendingPathOperations.where( + (op) => !op.committed, + )) { + operation ..retryable = false ..errorDetail = 'Filesystem outcome requires reconciliation.'; - _showCaptureFailure(); - return false; } + _showCaptureFailure(); final settled = await _enqueue( _pathRemapOwner, _settlePendingPathOperations, @@ -2400,7 +2405,7 @@ class LocalHistoryController extends Notifier { }) async { if (id == _pathRemapOwner) { if (!automatic || _hasRetryableWork(id)) { - await _settlePendingPathOperations(); + await _settlePendingPathOperations(automatic: automatic); } return; } @@ -2758,8 +2763,59 @@ class LocalHistoryController extends Notifier { ? null : p.normalize(destinationPath); final frozenPreparedTargets = preparedTargets?.toList(growable: false); - if (_pendingPathOperations.isNotEmpty && - !await _enqueue(_pathRemapOwner, _settlePendingPathOperations)) { + // Settle namespace and explicitly frozen identity dependencies before + // resolving current path owners. Those predecessors can move an identity + // away from this path, making a later operation on it independent. + if (!await _settlePathOperationDependencies( + _LocalHistoryPathScope( + kind: kind, + sourcePath: normalizedSource, + destinationPath: normalizedDestination, + recursive: recursive, + targets: frozenPreparedTargets ?? const [], + documentIds: { + if (boundBufferId != null) + if (_documentIdsByBuffer[boundBufferId] case final documentId?) + documentId, + }, + ownerIds: {if (boundBufferId != null) boundBufferId}, + ), + )) { + throw const LocalHistoryStorageException( + 'An earlier Local History path reconciliation is still pending.', + ); + } + final dependencyOwners = { + ..._ownersAssociatedWithPath(normalizedSource), + if (normalizedDestination != null) + ..._ownersAssociatedWithPath(normalizedDestination), + if (boundBufferId != null) boundBufferId, + }; + final dependencyTargets = [ + ...?frozenPreparedTargets, + ...await _store.resolvePathTargets( + normalizedSource, + recursive: recursive, + ), + if (normalizedDestination != null) + ...await _store.resolvePathTargets( + normalizedDestination, + recursive: recursive, + ), + ]; + final dependencyScope = _LocalHistoryPathScope( + kind: kind, + sourcePath: normalizedSource, + destinationPath: normalizedDestination, + recursive: recursive, + targets: dependencyTargets, + documentIds: { + for (final owner in dependencyOwners) + if (_documentIdsByBuffer[owner] case final documentId?) documentId, + }, + ownerIds: dependencyOwners, + ); + if (!await _settlePathOperationDependencies(dependencyScope)) { throw const LocalHistoryStorageException( 'An earlier Local History path reconciliation is still pending.', ); @@ -2855,6 +2911,33 @@ class LocalHistoryController extends Notifier { preparedTargets: effectiveTargets, operation: (targets) async { final owners = _ownersAssociatedWithPath(normalizedSource); + // Source settlement can bind a previously unknown identity, and + // another caller can queue work while the store lock is awaited. + // Recheck the actual frozen targets before publishing filesystem + // changes; reconciliation cannot be retried inside the store lock. + final actualScope = _LocalHistoryPathScope( + kind: kind, + sourcePath: normalizedSource, + destinationPath: normalizedDestination, + recursive: recursive, + targets: [...dependencyTargets, ...targets], + documentIds: { + ...dependencyScope.documentIds, + if (boundBufferId != null) + if (_documentIdsByBuffer[boundBufferId] case final documentId?) + documentId, + }, + ownerIds: {...dependencyOwners, ...owners}, + ); + if (_pendingPathOperations.any( + (operation) => + !operation.settled && + operation.scope.conflictsWith(actualScope), + )) { + throw const LocalHistoryStorageException( + 'An earlier Local History path reconciliation is still pending.', + ); + } staged = switch (kind) { LocalHistoryPathReconciliationKind.remap => _LocalHistoryPathRemap( operationId: _newPathOperationId(), @@ -3564,36 +3647,111 @@ class LocalHistoryController extends Notifier { if (operationId.isNotEmpty) _retiredPathOperationIds.add(operationId); } - Future _settlePendingPathOperations() async { - while (_pendingPathOperations.isNotEmpty) { - final operation = _pendingPathOperations.first; - if (!operation.committed) return false; + Future _settlePathOperationDependencies(_LocalHistoryPathScope scope) { + if (_pendingPathOperations.isEmpty) return Future.value(true); + return _enqueue( + _pathRemapOwner, + () => _settlePendingPathOperations(requiredBy: scope), + ); + } + + Future _settlePromotionPathDependencies( + String bufferId, + String? documentId, + String destinationPath, + ) async { + if (_pendingPathOperations.isEmpty) return true; + if (!await _settlePathOperationDependencies( + _LocalHistoryPathScope( + kind: LocalHistoryPathReconciliationKind.deletion, + sourcePath: destinationPath, + recursive: false, + documentIds: {if (documentId != null) documentId}, + ownerIds: {bufferId}, + ), + )) { + return false; + } + if (_pendingPathOperations.isEmpty) return true; + return _settlePathOperationDependencies( + _LocalHistoryPathScope( + kind: LocalHistoryPathReconciliationKind.deletion, + sourcePath: destinationPath, + recursive: false, + targets: await _store.resolvePathTargets( + destinationPath, + recursive: false, + ), + documentIds: {if (documentId != null) documentId}, + ownerIds: {bufferId}, + ), + ); + } + + Future _settlePendingPathOperations({ + _LocalHistoryPathScope? requiredBy, + bool automatic = false, + }) async { + final queued = _pendingPathOperations.toList(growable: false); + final required = <_LocalHistoryPathOperation>{}; + if (requiredBy != null) { + // Include transitive prerequisites, in reverse queue order. A later + // operation must never overtake an unresolved predecessor on which it + // depends, even when that predecessor does not touch the new operation. + final scopes = [requiredBy]; + for (final operation in queued.reversed) { + if (!operation.settled && scopes.any(operation.scope.conflictsWith)) { + required.add(operation); + scopes.add(operation.scope); + } + } + } + for (final operation in queued) { + if (requiredBy != null && !required.contains(operation)) continue; + final index = _pendingPathOperations.indexOf(operation); + if (index < 0) continue; + if (automatic && !operation.retryable) continue; + if (_pendingPathOperations + .take(index) + .any( + (earlier) => + !earlier.settled && + earlier.scope.conflictsWith(operation.scope), + )) { + continue; + } + if (!operation.committed) continue; + if (operation.settled && + operation.retainUntilAcknowledged && + !operation.acknowledgementRequested) { + continue; + } if (!operation.applied) { try { await operation.apply(_store); } on Object catch (error) { - if (identical(_pendingPathOperations.firstOrNull, operation)) { + if (_pendingPathOperations.contains(operation)) { operation.retryable = _captureErrorIsRetryable(error); operation.errorDetail = error.toString(); _showCaptureFailure(); if (operation.retryable) _scheduleCheckpoint(_pathRemapOwner); } - return false; + continue; } } - if (!identical(_pendingPathOperations.firstOrNull, operation)) { - return false; - } + if (!_pendingPathOperations.contains(operation)) continue; operation ..applied = true ..retryable = true ..errorDetail = null; _showCaptureFailure(); if (!await refresh()) { - if (identical(_pendingPathOperations.firstOrNull, operation)) { + if (_pendingPathOperations.contains(operation)) { + operation.errorDetail = + 'Committed Local History reconciliation refresh pending.'; _scheduleCheckpoint(_pathRemapOwner); } - return false; + continue; } _rebaseQueuedPathTargetsAfter(operation); if (operation is _LocalHistoryPathDeletion) { @@ -3606,9 +3764,9 @@ class LocalHistoryController extends Notifier { } if (operation.retainUntilAcknowledged && !operation.acknowledgementRequested) { - return true; + continue; } - _pendingPathOperations.removeAt(0); + _pendingPathOperations.remove(operation); _rememberRetiredPathOperation(operation.operationId); for (final owner in operation.affectedOwners) { if (_closedBuffers.contains(owner) && !_hasOutstandingWork(owner)) { @@ -3618,7 +3776,10 @@ class LocalHistoryController extends Notifier { } } } - return true; + return (requiredBy == null ? _pendingPathOperations : required).every( + (operation) => + !_pendingPathOperations.contains(operation) || operation.settled, + ); } void _rebaseQueuedPathTargetsAfter(_LocalHistoryPathOperation committed) { @@ -5068,8 +5229,12 @@ class LocalHistoryController extends Notifier { )) { return true; } - if (_pathOperationBlocksOwner(bufferId, promotion.destinationPath) && - !await _enqueue(_pathRemapOwner, _settlePendingPathOperations)) { + _pathOperationBlocksOwner(bufferId, promotion.destinationPath); + if (!await _settlePromotionPathDependencies( + bufferId, + promotion.documentId, + promotion.destinationPath, + )) { return false; } if (!_operationIsCurrent( @@ -5616,7 +5781,22 @@ class LocalHistoryController extends Notifier { bool _hasRetryableWork(String id) { if (id == _pathRemapOwner) { - return _pendingPathOperations.firstOrNull?.retryable == true; + for (var index = 0; index < _pendingPathOperations.length; index++) { + final operation = _pendingPathOperations[index]; + if (operation.settled || !operation.retryable || !operation.committed) { + continue; + } + if (!_pendingPathOperations + .take(index) + .any( + (earlier) => + !earlier.settled && + earlier.scope.conflictsWith(operation.scope), + )) { + return true; + } + } + return false; } bool retryable(_LocalHistoryFailureStage stage) => _failure(id, stage)?.retryable != false; @@ -6003,6 +6183,54 @@ class _LocalHistoryCaptureFailure { }; } +/// The namespaces reserved by a reconciliation and the stable identities it +/// can change. Remaps always include descendants, regardless of the serialized +/// `recursive` flag (which is only used by deletions). +class _LocalHistoryPathScope { + _LocalHistoryPathScope({ + required LocalHistoryPathReconciliationKind kind, + required String sourcePath, + String? destinationPath, + required bool recursive, + Iterable targets = const [], + Set documentIds = const {}, + this.ownerIds = const {}, + }) : documentIds = { + ...documentIds, + for (final target in targets) target.documentId, + }, + namespaces = [ + ( + path: p.normalize(sourcePath), + recursive: + kind == LocalHistoryPathReconciliationKind.remap || recursive, + ), + if (destinationPath != null) + ( + path: p.normalize(destinationPath), + recursive: kind == LocalHistoryPathReconciliationKind.remap, + ), + for (final target in targets) + (path: p.normalize(target.expectedPath), recursive: false), + ]; + + final Set documentIds; + final Set ownerIds; + final List<({String path, bool recursive})> namespaces; + + bool conflictsWith(_LocalHistoryPathScope other) => + documentIds.any(other.documentIds.contains) || + ownerIds.any(other.ownerIds.contains) || + namespaces.any( + (left) => other.namespaces.any( + (right) => + p.equals(left.path, right.path) || + left.recursive && p.isWithin(left.path, right.path) || + right.recursive && p.isWithin(right.path, left.path), + ), + ); +} + abstract class _LocalHistoryPathOperation { _LocalHistoryPathOperation( Set affectedOwners, @@ -6048,6 +6276,7 @@ abstract class _LocalHistoryPathOperation { final bool retainUntilAcknowledged; final String? commitEvidenceOperationId; bool applied = false; + bool get settled => applied && errorDetail == null; bool acknowledgementRequested = false; Set get documentIds => targets.keys.toSet(); bool retryable = true; @@ -6056,6 +6285,18 @@ abstract class _LocalHistoryPathOperation { LocalHistoryWarningKind get warningKind; String get warningPath; LocalHistoryPathReconciliation get reconciliation; + _LocalHistoryPathScope get scope { + final value = reconciliation; + return _LocalHistoryPathScope( + kind: value.kind, + sourcePath: value.sourcePath, + destinationPath: value.destinationPath, + recursive: value.recursive, + targets: targets.values, + ownerIds: affectedOwners, + ); + } + bool blocksPath(String path); Future apply(LocalHistoryStore store) => store.reconcilePath(reconciliation); diff --git a/lib/src/markdown/markdown_ast_adapter.dart b/lib/src/markdown/markdown_ast_adapter.dart index 339d780e..3462755c 100644 --- a/lib/src/markdown/markdown_ast_adapter.dart +++ b/lib/src/markdown/markdown_ast_adapter.dart @@ -861,6 +861,7 @@ class MarkdownAstAdapter { closing: range.closing, isSourceLineBreak: entry.key.kind == BusyInlineKind.hardBreak, isRawHtmlText: true, + rawHtmlFragment: (start: mappingStart, end: mappingEnd), sourceLineBreakOffset: range.sourceLineBreakOffset == null ? null : mappingStart + range.sourceLineBreakOffset!, diff --git a/lib/src/markdown/markdown_source_annotation.dart b/lib/src/markdown/markdown_source_annotation.dart index 137e6f5c..9ec25592 100644 --- a/lib/src/markdown/markdown_source_annotation.dart +++ b/lib/src/markdown/markdown_source_annotation.dart @@ -37,6 +37,7 @@ class BusyMarkMappedInlineRange { this.isReference = false, this.isSourceLineBreak = false, this.isRawHtmlText = false, + this.rawHtmlFragment, this.sourceLineBreakOffset, }); @@ -57,6 +58,10 @@ class BusyMarkMappedInlineRange { /// This inline came from Markdown's literal raw-HTML text node. final bool isRawHtmlText; + + /// The complete inline HTML fragment whose outer whitespace is trimmed by + /// the HTML adapter. Distinct Markdown siblings are outside this interval. + final ({int start, int end})? rawHtmlFragment; final int? sourceLineBreakOffset; } diff --git a/lib/src/markdown/markdown_source_map.dart b/lib/src/markdown/markdown_source_map.dart index 3a27abd8..17a70094 100644 --- a/lib/src/markdown/markdown_source_map.dart +++ b/lib/src/markdown/markdown_source_map.dart @@ -772,6 +772,7 @@ class BusyMarkInlineParserContext { isReference: mappedRange.isReference, isSourceLineBreak: mappedRange.isSourceLineBreak, isRawHtmlText: mappedRange.isRawHtmlText, + rawHtmlFragment: mappedRange.rawHtmlFragment, sourceLineBreakOffset: mappedRange.sourceLineBreakOffset, ); } @@ -804,6 +805,7 @@ class BusyMarkInlineParserContext { isReference: range.isReference, isSourceLineBreak: range.isSourceLineBreak, isRawHtmlText: range.isRawHtmlText, + rawHtmlFragment: range.rawHtmlFragment, sourceLineBreakOffset: range.sourceLineBreakOffset, ); } @@ -888,6 +890,7 @@ class BusyMarkInlineParserContext { String source, { int sourceStart = 0, int? sourceEnd, + bool includeCodeBlocks = true, }) { if (source.isEmpty) return const []; final boundedStart = sourceStart.clamp(0, source.length).toInt(); @@ -903,6 +906,9 @@ class BusyMarkInlineParserContext { void collect(Iterable values) { for (final node in values) { + if (!includeCodeBlocks && node is md.Element && node.tag == 'pre') { + continue; + } if (node is md.UnparsedContent) { if (node.textContent.isNotEmpty) candidates.add(node.textContent); } else if (node is md.Text) { @@ -993,6 +999,12 @@ class BusyMarkInlineParserContext { isReference: range.isReference, isSourceLineBreak: range.isSourceLineBreak, isRawHtmlText: range.isRawHtmlText, + rawHtmlFragment: range.rawHtmlFragment == null + ? null + : ( + start: projection.rawStartFor(range.rawHtmlFragment!.start), + end: projection.rawEndFor(range.rawHtmlFragment!.end), + ), sourceLineBreakOffset: range.sourceLineBreakOffset == null ? null : projection.rawStartFor(range.sourceLineBreakOffset!), diff --git a/lib/src/markdown/raw_html_adapter.dart b/lib/src/markdown/raw_html_adapter.dart index 66063c57..c2f67e17 100644 --- a/lib/src/markdown/raw_html_adapter.dart +++ b/lib/src/markdown/raw_html_adapter.dart @@ -51,7 +51,11 @@ class RawHtmlInlineParseResult { } class RawHtmlAdapter { - const RawHtmlAdapter(); + const RawHtmlAdapter({this.preserveInlineWhitespace = false}); + + /// Clipboard selections can explicitly include spaces at inline edges. + /// Callers enabling this must first remove structural HTML layout whitespace. + final bool preserveInlineWhitespace; RawHtmlBlockParseResult? parseRawHtmlBlock( String rawSource, @@ -379,7 +383,7 @@ class RawHtmlAdapter { for (final node in nodes) { if (node is html.Text) { - if (node.data.trim().isNotEmpty) { + if (node.data.trim().isNotEmpty || preserveInlineWhitespace) { _appendInlineText(inlineBuffer, _collapseHtmlWhitespace(node.data)); } continue; @@ -620,7 +624,7 @@ class RawHtmlAdapter { final nestedBlocks = []; for (final child in element.nodes) { if (child is html.Text) { - if (child.data.trim().isNotEmpty) { + if (child.data.trim().isNotEmpty || preserveInlineWhitespace) { _appendInlineText(inlines, _collapseHtmlWhitespace(child.data)); } continue; @@ -818,7 +822,15 @@ class RawHtmlAdapter { node.data, ignoredPositionMarkers: mapping?.positionMarkers ?? const [], ); - return [BusyInline(kind: BusyInlineKind.text, text: text)]; + final inline = BusyInline(kind: BusyInlineKind.text, text: text); + final span = node.sourceSpan; + if (mapping != null && span != null) { + mapping.ranges[inline] = RawHtmlInlineSourceRange( + start: mapping.projection.rawStartFor(span.start.offset), + end: mapping.projection.rawEndFor(span.end.offset), + ); + } + return [inline]; } if (node is html.Element) { return _inlineFromElement(node, mapping: mapping); @@ -919,16 +931,22 @@ class RawHtmlAdapter { final closingStart = authoredClosing == null ? null : mapping.projection.rawStartFor(authoredClosing.start); - mapping.ranges[result.single] = RawHtmlInlineSourceRange( - start: start, - end: end, - opening: mapping.projection.source.substring(start, openingEnd), - closing: closingStart == null || closingStart >= end - ? null - : mapping.projection.source.substring(closingStart, end), - sourceLineBreakOffset: tag == 'br' - ? mapping.layout.claimForBreak(start, end) - : null, + // Transparent HTML containers can return their existing child. Keep + // that child's authored text/break interval rather than widening it to + // the surrounding tag and attributes. + mapping.ranges.putIfAbsent( + result.single, + () => RawHtmlInlineSourceRange( + start: start, + end: end, + opening: mapping.projection.source.substring(start, openingEnd), + closing: closingStart == null || closingStart >= end + ? null + : mapping.projection.source.substring(closingStart, end), + sourceLineBreakOffset: tag == 'br' + ? mapping.layout.claimForBreak(start, end) + : null, + ), ); } return result; @@ -939,6 +957,7 @@ class RawHtmlAdapter { Map? ranges, Iterable ignoredPositionMarkers = const [], }) { + if (preserveInlineWhitespace) return inlines.toList(); if (inlines.isEmpty) { return const []; } @@ -992,6 +1011,9 @@ class RawHtmlAdapter { String value, { Iterable ignoredPositionMarkers = const [], }) { + if (preserveInlineWhitespace && !RegExp(r'[\r\n\t\f]').hasMatch(value)) { + return value.replaceAll(RegExp(r'[^\S ]+'), ' '); + } final markers = ignoredPositionMarkers .where((marker) => marker.isNotEmpty) .toList(growable: false); diff --git a/lib/src/nextcloud_notes/application/notes_repository.dart b/lib/src/nextcloud_notes/application/notes_repository.dart index 58bc7fba..be8147e7 100644 --- a/lib/src/nextcloud_notes/application/notes_repository.dart +++ b/lib/src/nextcloud_notes/application/notes_repository.dart @@ -35,6 +35,39 @@ class NotesRepository { final _accountErrors = {}; final _syncs = >{}; final _removing = {}; + final _creationResolutions = {}; + final _bindingCandidates = {}; + final _creationBindingGuards = )>{}; + + /// Every controller registers its open tabs, including unsaved candidate tabs. + void addCreationBindingGuard(bool Function(String, Set) guard) => + _creationBindingGuards.add(guard); + void removeCreationBindingGuard(bool Function(String, Set) guard) => + _creationBindingGuards.remove(guard); + bool isCreationCandidateBinding(String localId) => + _bindingCandidates.contains(localId); + + NotesCreationReview creationReview(String localId, NoteState candidate) { + final note = _require(localId); + final attempt = note.creationAttempt; + if (note.serverId != null || + note.syncState != NoteSyncState.creationUncertain || + attempt == null || + !attempt.candidateServerIds.contains(candidate.id)) { + throw const NotesException( + NotesFailureCode.conflict, + 'Refresh and review the uncertain creation again.', + ); + } + return NotesCreationReview( + localId: localId, + accountId: note.accountId, + attemptId: attempt.id, + revision: note.revision, + candidate: candidate, + ); + } + final _uuid = const Uuid(); Future _mutations = Future.value(); Future? _initialization; @@ -341,6 +374,16 @@ class NotesRepository { 'Nextcloud may have created this note. Refresh and resolve the uncertain result before creating another.', ); } else { + // A definitive rejection establishes that this request created no note. + await _mutate(() async { + final current = _require(id); + if (current.creationAttempt?.id != sent.creationAttempt?.id) { + return; + } + final rejected = current.copyWith(creationAttempt: null); + await store.saveNote(rejected); + _notes[id] = rejected; + }); await _recordError(id, error); } continue; @@ -365,6 +408,7 @@ class NotesRepository { } bool _isBlocked(NextcloudNote note) => + (note.serverId == null && note.creationAttempt != null) || note.error || (note.readonly && (note.base == null || @@ -388,6 +432,9 @@ class NotesRepository { NextcloudAccount account, NotesListResult list, ) => _mutate(() async { + if (_removing.contains(account.id) || !_accounts.containsKey(account.id)) { + return; + } final updated = []; final existing = { for (final n in _notes.values.where( @@ -402,48 +449,29 @@ class NotesRepository { n.syncState == NoteSyncState.creationUncertain, ) .toList(); - final adoptedIds = {}; for (final note in uncertain) { final attempt = note.creationAttempt; - if (attempt == null) { - continue; // Older uncertain operations need user resolution. - } - final matches = list.notes.where(attempt.matches).toList(); - final plausible = list.notes.where(attempt.plausiblyMatches).toList(); - final candidate = matches.length == 1 ? matches.single : null; - final twin = candidate == null ? null : existing[candidate.id]; - final uniqueAttempt = - candidate != null && - uncertain - .where( - (n) => - n.creationAttempt?.plausiblyMatches(candidate) ?? false, - ) - .length == - 1; - // Empty content plus a second-resolution timestamp is weak evidence. - // Likewise, never absorb another locally edited instance of a candidate. - if (uniqueAttempt && candidate.content.isNotEmpty && twin == null) { - updated.add(_adoptCreation(note, candidate)); - adoptedIds.add(candidate.id); - } else { - updated.add( - note.copyWith( - remote: plausible.length == 1 ? plausible.single : null, - creationAttempt: attempt.withCandidateServerIds( - plausible.map((value) => value.id), - ), - errorMessage: plausible.isEmpty - ? note.errorMessage - : plausible.length == 1 - ? 'A possible server note was found. Review and deliberately adopt it or create a separate note.' - : '${plausible.length} possible server notes were found. Select one deliberately or create a separate note.', + if (attempt == null) continue; + // Payload equality is correlation evidence, never proof of identity. + final plausible = list.notes.where(attempt.plausiblyMatches).toList() + ..sort( + (a, b) => (attempt.matches(b) ? 1 : 0).compareTo( + attempt.matches(a) ? 1 : 0, ), ); - } + updated.add( + note.copyWith( + remote: plausible.length == 1 ? plausible.single : null, + creationAttempt: attempt.withCandidateServerIds( + plausible.map((n) => n.id), + ), + errorMessage: plausible.isEmpty + ? 'The creation outcome remains unknown. No matching note was found; this does not prove the request failed.' + : 'Possible server notes were found. Review and deliberately use one or create a separate note.', + ), + ); } for (final remote in list.notes) { - if (adoptedIds.contains(remote.id)) continue; final current = existing[remote.id]; if (current == null) { updated.add(_fromRemote(_uuid.v4(), account.id, remote)); @@ -561,6 +589,14 @@ class NotesRepository { Future _markCreationSending(String id) => _mutate(() async { final current = _require(id); + if (current.serverId != null || + current.creationAttempt != null || + _isBlocked(current)) { + throw const NotesException( + NotesFailureCode.conflict, + 'Resolve the previous creation attempt before creating another.', + ); + } final attributes = await NotesApiClient.creationAttributes(current); final attempt = NotesCreationAttempt( id: _uuid.v4(), @@ -590,13 +626,13 @@ class NotesRepository { final attempt = current.creationAttempt!; final wire = attempt.attributes; final latest = current.revision == attempt.revision; - final staged = current.content != wire['content']; + final staged = current.content != remote.content; final adopted = current.copyWith( serverId: remote.id, etag: remote.etag, base: remote, remote: null, - content: latest && !staged ? remote.content : current.content, + content: current.content, title: current.title == wire['title'] ? remote.title : current.title, category: current.category == wire['category'] ? remote.category @@ -639,6 +675,17 @@ class NotesRepository { NoteState remote, ) => _mutate(() async { final current = _require(sent.localId); + if (_removing.contains(current.accountId) || + !_accounts.containsKey(current.accountId) || + (sent.serverId == null && + (current.serverId != null || + current.creationAttempt?.id != sent.creationAttempt?.id)) || + (sent.serverId != null && current.serverId != sent.serverId)) { + throw const NotesException( + NotesFailureCode.conflict, + 'This response no longer belongs to the active request.', + ); + } if (remote.error) { final unavailable = current.copyWith( serverId: remote.id, @@ -761,8 +808,19 @@ class NotesRepository { String originalLocalId, { String? content, String? title, + bool creationDecision = false, }) async { final original = _require(originalLocalId); + final separateId = original.creationAttempt?.separateNoteLocalId; + if (creationDecision && separateId != null) return _require(separateId); + if (creationDecision && + (original.serverId != null || + original.syncState != NoteSyncState.creationUncertain)) { + throw const NotesException( + NotesFailureCode.conflict, + 'Review the creation outcome again.', + ); + } final source = content ?? original.content; final references = await scanNotesAttachmentReferences(source); final originals = await store.attachments(originalLocalId); @@ -853,7 +911,32 @@ class NotesRepository { content: rewritten, ); return _mutate(() async { - await store.createWithAttachments(recovered, copied.values.toList()); + final current = _require(originalLocalId); + if (_removing.contains(original.accountId) || + !_accounts.containsKey(original.accountId) || + current.revision != original.revision || + current.creationAttempt?.id != original.creationAttempt?.id || + (creationDecision && current.serverId != null)) { + throw const NotesException( + NotesFailureCode.conflict, + 'Local work or the account changed during recovery. Review again.', + ); + } + final existingId = current.creationAttempt?.separateNoteLocalId; + if (creationDecision && existingId != null) return _require(existingId); + final decided = creationDecision && current.creationAttempt != null + ? current.copyWith( + creationAttempt: current.creationAttempt!.withSeparateNote( + localId, + ), + ) + : null; + await store.createWithAttachments( + recovered, + copied.values.toList(), + additionalNotes: [if (decided != null) decided], + ); + if (decided != null) _notes[originalLocalId] = decided; _notes[localId] = recovered; _notify(); return recovered; @@ -1021,6 +1104,7 @@ class NotesRepository { Map metadataChoices = const {}, int? expectedRevision, int? creationCandidateServerId, + NotesCreationReview? creationReview, }) async { final original = _require(localId); if (expectedRevision != null && original.revision != expectedRevision) { @@ -1030,94 +1114,39 @@ class NotesRepository { ); } if (resolution == NoteConflictResolution.saveAsNew) { - await recoverAsNew(localId); + if (!_creationResolutions.add(localId)) { + throw const NotesException( + NotesFailureCode.conflict, + 'A creation decision is already in progress.', + ); + } + try { + await recoverAsNew( + localId, + creationDecision: original.serverId == null, + ); + } finally { + _creationResolutions.remove(localId); + } return; } - if (original.serverId == null) { - NoteState? selectedRemote = original.remote; - if (creationCandidateServerId != null) { - if (!(original.creationAttempt?.candidateServerIds.contains( - creationCandidateServerId, - ) ?? - false)) { - throw const NotesException( - NotesFailureCode.conflict, - 'That note is not a current creation candidate. Refresh and review the possible matches again.', - ); - } - selectedRemote = _notes.values - .where( - (candidate) => - candidate.accountId == original.accountId && - candidate.serverId == creationCandidateServerId, - ) - .map((candidate) => candidate.base) - .whereType() - .firstOrNull; - } - if (resolution != NoteConflictResolution.takeRemote || - selectedRemote == null) { + if (resolution == NoteConflictResolution.useServerNote) { + if (creationReview == null || + creationCandidateServerId != creationReview.candidate.id) { throw const NotesException( NotesFailureCode.conflict, - 'An uncertain creation must be deliberately adopted or recovered as a new note.', + 'Select and review a server note before confirming adoption.', ); } - await _mutate(() async { - if (_require(localId).revision != original.revision) { - throw const NotesException( - NotesFailureCode.conflict, - 'New local edits appeared while resolving this note. Review them before adoption.', - ); - } - final remote = selectedRemote!; - final duplicate = _notes.values - .where( - (n) => - n.accountId == original.accountId && - n.serverId == remote.id && - n.localId != original.localId, - ) - .toList(); - for (final twin in duplicate) { - final attachments = await store.attachments(twin.localId); - final pendingAttachments = attachments.any( - (attachment) => { - 'pending', - 'uploading', - 'uncertain', - 'uploaded', - 'deletePending', - }.contains(attachment.state), - ); - if (twin.hasPendingChanges || pendingAttachments) { - throw const NotesException( - NotesFailureCode.conflict, - 'The downloaded instance of this note contains durable local changes or pending attachments. Preserve or resolve those changes before adopting it.', - ); - } - } - // Adopt into the stable logical identity atomically, remove downloaded twin. - final adopted = original.creationAttempt != null - ? _adoptCreation(original, remote) - : _fromRemote( - original.localId, - original.accountId, - remote, - revision: original.revision, - previous: original, - ); - await store.commit( - notes: [adopted], - removeNotes: duplicate.map((n) => n.localId).toList(), - ); - for (final n in duplicate) { - _notes.remove(n.localId); - } - _notes[localId] = adopted; - _notify(); - }); + await _resolveCreation(original, creationReview); return; } + if (original.serverId == null) { + throw const NotesException( + NotesFailureCode.conflict, + 'An uncertain creation needs explicit server-note confirmation or a separate note.', + ); + } final client = await _clientForAccount(_accounts[original.accountId]!); late NoteState fresh; try { @@ -1221,6 +1250,157 @@ class NotesRepository { }); } + Future _resolveCreation( + NextcloudNote original, + NotesCreationReview review, + ) async { + if (!_creationResolutions.add(original.localId)) { + throw const NotesException( + NotesFailureCode.conflict, + 'A creation decision is already in progress.', + ); + } + final candidateIds = _notes.values + .where( + (n) => + n.accountId == original.accountId && + n.serverId == review.candidate.id, + ) + .map((n) => n.localId) + .toSet(); + _bindingCandidates.addAll(candidateIds); + try { + final account = _accounts[original.accountId]; + void validate() { + final current = _require(original.localId); + if (account == null || + _removing.contains(original.accountId) || + _accounts[original.accountId]?.server != account.server || + _accounts[original.accountId]?.loginName != account.loginName || + current.serverId != null || + current.syncState != NoteSyncState.creationUncertain || + review.localId != current.localId || + review.accountId != current.accountId || + current.creationAttempt?.id != review.attemptId || + current.creationAttempt?.separateNoteLocalId != null || + current.revision != review.revision || + !(current.creationAttempt?.candidateServerIds.contains( + review.candidate.id, + ) ?? + false)) { + throw const NotesException( + NotesFailureCode.conflict, + 'The account, local edits or creation decision changed. Review again.', + ); + } + } + + validate(); + final running = _syncs[original.accountId]; + if (running != null) await running; + validate(); + final client = await _clientForAccount(account!); + late NoteState fresh; + try { + fresh = await client.get(review.candidate.id); + } on NotesException catch (error) { + await _mutate(() async { + validate(); + final current = _require(original.localId); + final ids = {...current.creationAttempt!.candidateServerIds}; + if (error.code == NotesFailureCode.missing) { + ids.remove(review.candidate.id); + } + final unresolved = current.copyWith( + remote: null, + creationAttempt: current.creationAttempt!.withCandidateServerIds( + ids, + ), + errorMessage: + 'The selected server note is unavailable. Refresh and review again.', + ); + await store.saveNote(unresolved); + _notes[current.localId] = unresolved; + _notify(); + }); + rethrow; + } + await _mutate(() async { + validate(); + final current = _require(original.localId); + if (jsonEncode(fresh.toJson()) != + jsonEncode(review.candidate.toJson()) || + fresh.error || + fresh.readonly) { + final unresolved = current.copyWith( + remote: fresh, + errorMessage: + 'The selected server note changed or is read-only. Review its current state before deciding.', + ); + await store.saveNote(unresolved); + _notes[current.localId] = unresolved; + _notify(); + throw NotesException( + NotesFailureCode.conflict, + unresolved.errorMessage!, + remote: fresh, + ); + } + final duplicates = _notes.values + .where( + (n) => + n.accountId == current.accountId && + n.serverId == fresh.id && + n.localId != current.localId, + ) + .toList(); + candidateIds.addAll(duplicates.map((n) => n.localId)); + _bindingCandidates.addAll(candidateIds); + for (final twin in duplicates) { + final attachments = await store.attachments(twin.localId); + if (twin.hasPendingChanges || + attachments.any( + (a) => { + 'pending', + 'uploading', + 'uncertain', + 'uploaded', + 'deletePending', + }.contains(a.state), + )) { + throw const NotesException( + NotesFailureCode.conflict, + 'The downloaded candidate has local changes or pending attachments. Preserve or resolve them first.', + ); + } + } + validate(); + final ids = duplicates.map((n) => n.localId).toSet(); + if (_creationBindingGuards.any( + (guard) => !guard(current.localId, ids), + )) { + throw const NotesException( + NotesFailureCode.conflict, + 'New editor changes appeared or a downloaded candidate is still open. Preserve those edits, close candidate tabs, and review again.', + ); + } + final adopted = _adoptCreation(current, fresh); + // Binding/outbox and clean-twin removal are a single SQLite transaction. + await store.commit(notes: [adopted], removeNotes: ids.toList()); + for (final id in ids) { + _notes.remove(id); + } + _notes[current.localId] = adopted; + _baseGenerations[current.localId] = + (_baseGenerations[current.localId] ?? 0) + 1; + _notify(); + }); + } finally { + _bindingCandidates.removeAll(candidateIds); + _creationResolutions.remove(original.localId); + } + } + /// Online refresh plus explicit action only. The API has no atomic conditional DELETE. Future delete(String localId) async { final original = _require(localId); diff --git a/lib/src/nextcloud_notes/data/notes_store.dart b/lib/src/nextcloud_notes/data/notes_store.dart index 8d9c2451..93ddb9b8 100644 --- a/lib/src/nextcloud_notes/data/notes_store.dart +++ b/lib/src/nextcloud_notes/data/notes_store.dart @@ -150,8 +150,9 @@ class NotesStore { Future createWithAttachments( NextcloudNote note, - List<({NotesAttachment attachment, Uint8List bytes})> attachments, - ) async { + List<({NotesAttachment attachment, Uint8List bytes})> attachments, { + List additionalNotes = const [], + }) async { await _call('createWithAttachments', [ note.toJson(), attachments @@ -164,6 +165,7 @@ class NotesStore { ], ) .toList(), + additionalNotes.map((n) => n.toJson()).toList(), ]); } @@ -424,6 +426,12 @@ Object? _execute(Database db, String action, dynamic argument) { final a = argument as List; db.execute('BEGIN IMMEDIATE'); try { + for (final note in a[2] as List) { + _writeNote( + db, + NextcloudNote.fromJson(Map.from(note as Map)), + ); + } _writeNote( db, NextcloudNote.fromJson(Map.from(a[0] as Map)), diff --git a/lib/src/nextcloud_notes/domain/notes_models.dart b/lib/src/nextcloud_notes/domain/notes_models.dart index f189b15d..3232e01d 100644 --- a/lib/src/nextcloud_notes/domain/notes_models.dart +++ b/lib/src/nextcloud_notes/domain/notes_models.dart @@ -15,7 +15,13 @@ enum NoteSyncState { deletedRemotely, } -enum NoteConflictResolution { keepLocal, takeRemote, saveAsNew, merge } +enum NoteConflictResolution { + keepLocal, + takeRemote, + saveAsNew, + merge, + useServerNote, +} enum NotesFailureCode { authentication, @@ -190,6 +196,7 @@ class NotesCreationAttempt { required this.wireBody, required Set knownServerIds, Set candidateServerIds = const {}, + this.separateNoteLocalId, }) : knownServerIds = Set.unmodifiable(knownServerIds), candidateServerIds = Set.unmodifiable(candidateServerIds); @@ -199,6 +206,7 @@ class NotesCreationAttempt { final String wireBody; final Set knownServerIds; final Set candidateServerIds; + final String? separateNoteLocalId; Map get attributes => jsonDecode(wireBody) as Map; @@ -226,8 +234,19 @@ class NotesCreationAttempt { wireBody: wireBody, knownServerIds: knownServerIds, candidateServerIds: ids.toSet(), + separateNoteLocalId: separateNoteLocalId, ); + NotesCreationAttempt withSeparateNote(String localId) => NotesCreationAttempt( + id: id, + revision: revision, + localContent: localContent, + wireBody: wireBody, + knownServerIds: knownServerIds, + candidateServerIds: candidateServerIds, + separateNoteLocalId: localId, + ); + Map toJson() => { 'id': id, 'revision': revision, @@ -235,10 +254,12 @@ class NotesCreationAttempt { 'wireBody': wireBody, 'knownServerIds': knownServerIds.toList(), 'candidateServerIds': candidateServerIds.toList(), + 'separateNoteLocalId': separateNoteLocalId, }; factory NotesCreationAttempt.fromJson(Map json) => NotesCreationAttempt( id: json['id'] as String, + separateNoteLocalId: json['separateNoteLocalId'] as String?, revision: json['revision'] as int, localContent: json['localContent'] as String, wireBody: json['wireBody'] as String, @@ -249,6 +270,22 @@ class NotesCreationAttempt { ); } +/// The immutable comparison explicitly approved by the user. +class NotesCreationReview { + const NotesCreationReview({ + required this.localId, + required this.accountId, + required this.attemptId, + required this.revision, + required this.candidate, + }); + final String localId; + final String accountId; + final String attemptId; + final int revision; + final NoteState candidate; +} + const _unchanged = Object(); /// In-memory provenance of an editor buffer. A server observation advances the diff --git a/lib/src/nextcloud_notes/presentation/notes_sidebar.dart b/lib/src/nextcloud_notes/presentation/notes_sidebar.dart index a98cce0a..ec2db9b9 100644 --- a/lib/src/nextcloud_notes/presentation/notes_sidebar.dart +++ b/lib/src/nextcloud_notes/presentation/notes_sidebar.dart @@ -566,15 +566,27 @@ Future showNextcloudConflict( onPressed: () => Navigator.pop(context), ), BusyMarkDialogButton( - label: context.l10n.nextcloudNewNote, + label: note.serverId == null + ? context.l10n.nextcloudCreateSeparate + : context.l10n.nextcloudNewNote, onPressed: () => Navigator.pop(context, NoteConflictResolution.saveAsNew), ), - if (displayedRemote != null && !displayedRemote.error) + if (displayedRemote != null && + !displayedRemote.error && + (note.serverId != null || note.creationAttempt != null)) BusyMarkDialogButton( - label: context.l10n.nextcloudTakeRemote, - onPressed: () => - Navigator.pop(context, NoteConflictResolution.takeRemote), + label: note.serverId == null + ? context.l10n.nextcloudUseServerNote + : context.l10n.nextcloudTakeRemote, + onPressed: displayedRemote.readonly && note.serverId == null + ? null + : () => Navigator.pop( + context, + note.serverId == null + ? NoteConflictResolution.useServerNote + : NoteConflictResolution.takeRemote, + ), ), if (deletedRemotely) BusyMarkDialogButton( @@ -605,7 +617,7 @@ Future showNextcloudConflict( DropdownButton( isExpanded: true, value: creationCandidateServerId, - hint: Text(context.l10n.nextcloudTakeRemote), + hint: Text(context.l10n.nextcloudSelectCandidate), items: [ for (final candidate in creationCandidates) DropdownMenuItem( @@ -619,6 +631,22 @@ Future showNextcloudConflict( creationCandidateServerId = value; }), ), + if (note.serverId == null && displayedRemote != null) ...[ + Text( + '#${displayedRemote.id} · ${displayedRemote.title} · ${displayedRemote.category}', + ), + Text( + DateTime.fromMillisecondsSinceEpoch( + displayedRemote.modified * 1000, + ).toLocal().toString(), + ), + Text( + note.creationAttempt?.matches(displayedRemote) == true + ? context.l10n.nextcloudExactCandidate + : context.l10n.nextcloudPossibleCandidate, + ), + Text(context.l10n.nextcloudUseServerNoteExplanation), + ], if (note.serverId != null && !deletedRemotely) _UncertainAttachments(note: note), SizedBox( @@ -697,6 +725,38 @@ Future showNextcloudConflict( ); if (confirmed != true) return; } + if (note.serverId == null && resolution == NoteConflictResolution.saveAsNew) { + if (!context.mounted) return; + final confirmed = await showBusyMarkModalDialog( + context, + builder: (context) => BusyMarkDialogShell( + title: context.l10n.nextcloudCreateSeparate, + actions: [ + BusyMarkDialogButton( + label: context.l10n.cancel, + onPressed: () => Navigator.pop(context, false), + ), + BusyMarkDialogButton( + label: context.l10n.nextcloudCreateSeparate, + onPressed: () => Navigator.pop(context, true), + ), + ], + children: [Text(context.l10n.nextcloudCreateSeparateWarning)], + ), + ); + if (confirmed != true) return; + } + final selected = selectedCreationRemote(); + final review = + resolution == NoteConflictResolution.useServerNote && selected != null + ? NotesCreationReview( + localId: note.localId, + accountId: note.accountId, + attemptId: note.creationAttempt!.id, + revision: note.revision, + candidate: selected, + ) + : null; if (resolution != null) { await ref .read(workspaceControllerProvider.notifier) @@ -709,6 +769,7 @@ Future showNextcloudConflict( ? merged : null, creationCandidateServerId: creationCandidateServerId, + creationReview: review, ); } } diff --git a/lib/src/spellcheck/markdown_spelling_projection.dart b/lib/src/spellcheck/markdown_spelling_projection.dart index 6d9424d9..0b087350 100644 --- a/lib/src/spellcheck/markdown_spelling_projection.dart +++ b/lib/src/spellcheck/markdown_spelling_projection.dart @@ -40,6 +40,21 @@ final class MarkdownSpellingProjector { documentSource: source, mode: mode, ); + // Keep the document's block context: a scanner chunk can start with a + // literal list-like continuation that becomes a list if parsed alone. + final blockMappings = inlineParser.parsePositionedBlocks( + source, + includeCodeBlocks: false, + ); + final consumedMappings = {}; + final literalHtmlBlocks = [ + for (final block in _walkBlocks(parsed.busyDocument.blocks)) + if (block.sourceSpan != null && + block.inlines.length == 1 && + block.inlines.single.kind == BusyInlineKind.text && + block.inlines.single.text.contains('<')) + block, + ]; final excludedBlockSpans = [ ...parsed.codeBlocks.map((block) => block.span), for (final block in _walkBlocks(parsed.busyDocument.blocks)) @@ -253,7 +268,8 @@ final class MarkdownSpellingProjector { ]; final hardBreakSyntax = <_SourceInterval>[ for (final entry in mapped.ranges.entries) - if (entry.key.kind == BusyInlineKind.hardBreak) + if (entry.key.kind == BusyInlineKind.hardBreak && + !entry.value.isRawHtmlText) _SourceInterval( sourceBase + entry.value.start, sourceBase + entry.value.end, @@ -273,6 +289,16 @@ final class MarkdownSpellingProjector { end: mappedEnd, context: context, stripBlockSyntax: stripBlockSyntax, + // Rejected inline HTML is displayed as literal editable text. Only + // an exact match in its parser-owned block may bypass HTML scanning; + // protected HTML blocks and encoded/escaped text keep their policies. + interpretHtmlSyntax: !literalHtmlBlocks.any( + (block) => + block.sourceSpan!.startOffset <= mappedStart && + block.sourceSpan!.endOffset >= mappedEnd && + block.inlines.single.text == + source.substring(mappedStart, mappedEnd), + ), footnoteLabels: footnoteLabels, formattingSyntax: formattingSyntax, formattingWrappers: [ @@ -287,6 +313,31 @@ final class MarkdownSpellingProjector { imageCodeSyntax: imageCodeSyntax, positionedLineBreaks: positionedLineBreaks, hardBreakSyntax: hardBreakSyntax, + htmlLineBreaks: { + for (final entry in mapped.ranges.entries) + if (entry.key.kind == BusyInlineKind.hardBreak && + entry.value.isRawHtmlText) + sourceBase + entry.value.start: sourceBase + entry.value.end, + }, + htmlText: { + for (final entry in mapped.ranges.entries) + if (entry.key.kind == BusyInlineKind.text && + entry.value.isRawHtmlText) + sourceBase + entry.value.start: ( + end: sourceBase + entry.value.end, + text: entry.key.text, + ), + }, + htmlFragments: [ + for (final fragment in { + for (final range in mapped.ranges.values) + if (range.rawHtmlFragment case final fragment?) fragment, + }) + _SourceInterval( + sourceBase + fragment.start, + sourceBase + fragment.end, + ), + ], recognizedLinks: { for (final entry in mapped.ranges.entries) if (entry.key.kind == BusyInlineKind.link || @@ -385,7 +436,15 @@ final class MarkdownSpellingProjector { } final slice = source.substring(start, end); final mapped = stripBlockSyntax - ? inlineParser.parsePositionedBlocks(slice) + ? blockMappings + .where( + (leaf) => + leaf.sourceStart != null && + leaf.sourceEnd != null && + leaf.sourceStart! < end && + leaf.sourceEnd! > start, + ) + .toList() : [inlineParser.parseMapped(slice)]; if (stripBlockSyntax && mapped.isNotEmpty) { for (final semanticLeaf in mapped) { @@ -396,12 +455,13 @@ final class MarkdownSpellingProjector { continue; } if (leafEnd <= leafStart) continue; + if (!consumedMappings.add(semanticLeaf)) continue; addMapped( - start + leafStart, - start + leafEnd, + leafStart, + leafEnd, semanticLeaf, - sourceBase: start, - sourceLimit: end, + sourceBase: 0, + sourceLimit: source.length, stripBlockSyntax: true, context: context, ); @@ -957,6 +1017,9 @@ final class _MarkdownProseScanner { this.imageCodeSyntax = const [], this.positionedLineBreaks = const [], this.hardBreakSyntax = const [], + this.htmlLineBreaks = const {}, + this.htmlText = const {}, + this.htmlFragments = const [], this.recognizedLinks = const {}, this.recognizedAttributeSpans = const [], this.footnoteLabels = const {}, @@ -976,8 +1039,19 @@ final class _MarkdownProseScanner { final List<_MappedImageCode> imageCodeSyntax; final List positionedLineBreaks; final List<_SourceInterval> hardBreakSyntax; + final Map htmlLineBreaks; + final Map htmlText; + final List<_SourceInterval> htmlFragments; final Map recognizedLinks; final List<_SourceInterval> recognizedAttributeSpans; + late final _continuationEnds = { + for (final lineBreak in positionedLineBreaks) + if (lineBreak.sourceOffset case final offset?) + offset: + offset + + lineBreak.lineEnding.length + + lineBreak.continuationPrefix.length, + }; final List _groups = []; StringBuffer _text = StringBuffer(); final StringBuffer _tokenizationContext = StringBuffer(); @@ -985,46 +1059,44 @@ final class _MarkdownProseScanner { int? _tokenizationContextStart; int? _opaqueEnd; int? _consumedLinkEnd; + int? _consumedHtmlEnd; bool _inImageLabel = false; bool complete = true; List<_EmissionGroup> scan() { final lines = _lines(); + final continuationStarts = { + for (final lineBreak in positionedLineBreaks) + if (lineBreak.sourceOffset case final offset?) + offset + lineBreak.lineEnding.length: + offset + + lineBreak.lineEnding.length + + lineBreak.continuationPrefix.length, + }; for (var lineIndex = 0; lineIndex < lines.length; lineIndex++) { final line = lines[lineIndex]; - var contentStart = line.start; + // The block parser already located the first prose character and each + // continuation. Reinterpreting their prefixes would discard literal + // text such as "2. words" or misread nested container indentation. + final contentStart = stripBlockSyntax + ? continuationStarts[line.start] ?? line.start + : line.start; var contentEnd = line.contentEnd; - // Positioned leaves already start after their first block prefix. Do - // not reinterpret heading text such as "2. Paragraphs" as a list item. - // Continuation lines still carry their authored container prefixes. - if (stripBlockSyntax && - (lineIndex > 0 || - start == 0 || - source.codeUnitAt(start - 1) == 0x0a)) { - final rawLine = source.substring(contentStart, contentEnd); - if (_isSetextOrThematic(rawLine)) { - _barrier(); - continue; - } - final prefix = _blockPrefix.firstMatch(rawLine); - contentStart += prefix?.end ?? 0; - var isAtxHeading = false; - if (source.startsWith('#', contentStart)) { - final heading = RegExp( - r'^#{1,6}[ \t]+', - ).firstMatch(source.substring(contentStart, contentEnd)); - contentStart += heading?.end ?? 0; - isAtxHeading = heading != null; - } + if (stripBlockSyntax && lineIndex + 1 < lines.length) { + // Spaces before a soft break are omitted by the inline grammar; + // tabs are authored text. Hard breaks also own their backslash or + // spaces, including on the first line after a list/quote marker. while (contentEnd > contentStart && - _horizontalWhitespace(source.codeUnitAt(contentEnd - 1))) { + source.codeUnitAt(contentEnd - 1) == 0x20) { contentEnd--; } - final closingHeading = RegExp( - r'[ \t]+#+$', - ).firstMatch(source.substring(contentStart, contentEnd)); - if (isAtxHeading && closingHeading != null) { - contentEnd = contentStart + closingHeading.start; + for (final hardBreak in hardBreakSyntax) { + if (hardBreak.start >= contentStart && + hardBreak.start <= contentEnd && + hardBreak.end > line.contentEnd) { + contentEnd = hardBreak.start; + break; + } } } for (final attributes in recognizedAttributeSpans) { @@ -1036,13 +1108,17 @@ final class _MarkdownProseScanner { if (contentEnd > contentStart) _scanInline(contentStart, contentEnd); if (lineIndex + 1 < lines.length && _text.length > 0 && + !htmlFragments.any( + (span) => + span.start <= line.contentEnd && span.end > line.contentEnd, + ) && !(_consumedLinkEnd != null && lines[lineIndex + 1].start < _consumedLinkEnd!)) { final next = lines[lineIndex + 1]; _emit( ' ', - line.contentEnd, - next.start, + contentEnd, + continuationStarts[next.start] ?? next.start, SpellingTransformationKind.lineBreak, tokenizationLogical: '\n', ); @@ -1068,6 +1144,10 @@ final class _MarkdownProseScanner { void _scanInline(int rangeStart, int rangeEnd) { var cursor = rangeStart; while (cursor < rangeEnd) { + if (_consumedHtmlEnd case final htmlEnd? when cursor < htmlEnd) { + cursor = math.min(rangeEnd, htmlEnd); + continue; + } if (_consumedLinkEnd case final linkEnd? when cursor < linkEnd) { cursor = math.min(rangeEnd, linkEnd); continue; @@ -1089,6 +1169,27 @@ final class _MarkdownProseScanner { cursor = formattingEnd; continue; } + final htmlTextRange = _inImageLabel ? null : htmlText[cursor]; + if (htmlTextRange != null) { + _scanHtmlText(cursor, htmlTextRange.end, htmlTextRange.text); + _consumedHtmlEnd = htmlTextRange.end; + cursor = htmlTextRange.end; + continue; + } + final htmlBreakEnd = _inImageLabel ? null : htmlLineBreaks[cursor]; + if (htmlBreakEnd != null) { + _scanHtmlBreakAttributes(cursor, htmlBreakEnd); + _emit( + ' ', + cursor, + htmlBreakEnd, + SpellingTransformationKind.lineBreak, + tokenizationLogical: '\n', + ); + _consumedHtmlEnd = htmlBreakEnd; + cursor = htmlBreakEnd; + continue; + } final opaqueEnd = _opaqueEndAt(cursor); if (opaqueEnd != null) { _barrier(); @@ -1228,6 +1329,105 @@ final class _MarkdownProseScanner { } } + void _scanHtmlText(int rangeStart, int rangeEnd, String expected) { + final units = + < + ({ + String text, + int start, + int end, + SpellingTransformationKind transformation, + }) + >[]; + var cursor = rangeStart; + while (cursor < rangeEnd) { + final start = cursor; + var end = cursor + (_codePointAt(source, cursor) > 0xffff ? 2 : 1); + var text = source.substring(cursor, end); + var transformation = SpellingTransformationKind.identity; + final continuation = _continuationEnds[cursor]; + if (continuation != null && continuation <= rangeEnd) { + text = ' '; + end = continuation; + transformation = SpellingTransformationKind.lineBreak; + } else if (text == '\\' && + end < rangeEnd && + _commonMarkEscapableAsciiPunctuation(source.codeUnitAt(end))) { + text = source.substring(end, end + 1); + end++; + transformation = SpellingTransformationKind.markdownEscape; + } else if (text == '&') { + final entity = _entity.matchAsPrefix(source, cursor); + if (entity != null && entity.end <= rangeEnd) { + final encoded = entity.group(0)!; + final decoded = html.parseFragment(encoded).text ?? ''; + if (decoded.isNotEmpty && decoded != encoded) { + text = decoded; + end = entity.end; + transformation = SpellingTransformationKind.entity; + } + } + } + if (_htmlWhitespaceText.hasMatch(text)) { + text = ' '; + transformation = SpellingTransformationKind.lineBreak; + if (units.isNotEmpty && units.last.text == ' ') { + final previous = units.removeLast(); + units.add(( + text: ' ', + start: previous.start, + end: end, + transformation: transformation, + )); + cursor = end; + continue; + } + } + units.add(( + text: text, + start: start, + end: end, + transformation: transformation, + )); + cursor = end; + } + // The HTML adapter trims fragment edges, but preserves spaces inside + // formatting. Its semantic text decides which normalized edge survives. + if (units.isNotEmpty && + units.first.text == ' ' && + !expected.startsWith(' ')) { + units.removeAt(0); + } + if (units.isNotEmpty && units.last.text == ' ' && !expected.endsWith(' ')) { + units.removeLast(); + } + if (units.map((unit) => unit.text).join() != expected) { + complete = false; + return; + } + final addresses = spellingPlainAddress.allMatches(expected).toList(); + var logicalOffset = 0; + for (final unit in units) { + final logicalEnd = logicalOffset + unit.text.length; + if (addresses.any( + (address) => address.start < logicalEnd && address.end > logicalOffset, + )) { + _barrier(); + } else { + _emit(unit.text, unit.start, unit.end, unit.transformation); + } + logicalOffset = logicalEnd; + } + } + + void _scanHtmlBreakAttributes(int start, int end) { + // Multiline tag metadata is collected separately with the opaque HTML + // spans. Single-line breaks still expose their readable attributes. + if (!RegExp(r'\r|\n').hasMatch(source.substring(start, end))) { + _scanHumanReadableAttributes(start, end); + } + } + _MappedImageCode? _imageCodeAt(int offset) { for (final code in imageCodeSyntax) { if (code.start == offset) return code; @@ -1407,6 +1607,15 @@ final class _MarkdownProseScanner { final offset = lineBreak.sourceOffset; return offset != null && offset >= labelStart && offset < labelEnd; }).toList()..sort((a, b) => a.sourceOffset!.compareTo(b.sourceOffset!)); + final inlineBreaks = [ + ...hardBreakSyntax.where( + (span) => + !RegExp(r'\r|\n').hasMatch(source.substring(span.start, span.end)), + ), + if (!_inImageLabel) + for (final entry in htmlLineBreaks.entries) + _SourceInterval(entry.key, entry.value), + ]; var breakIndex = 0; while (cursor < labelEnd) { // Mapped children own their internal line boundaries. Traverse them @@ -1462,14 +1671,33 @@ final class _MarkdownProseScanner { ); } } - for (final span in hardBreakSyntax) { + for (final entry in htmlText.entries) { + if (_inImageLabel || + entry.key < cursor || + entry.value.end > labelEnd || + (nextBreak != null && entry.key > nextBreak)) { + continue; + } + if (owner == null || entry.key < owner.start) { + owner = ( + start: entry.key, + end: entry.value.end, + semanticBreaks: 0, + image: null, + imageCode: null, + inlineBreak: false, + ); + } + } + for (final span in inlineBreaks) { if (span.start < cursor || span.end > labelEnd || - (nextBreak != null && span.start > nextBreak) || - RegExp(r'\r|\n').hasMatch(source.substring(span.start, span.end))) { + (nextBreak != null && span.start > nextBreak)) { continue; } - if (owner == null || span.start < owner.start) { + // A mapped HTML break owns its semantic newline even when its tag + // also appears in the generic multiline HTML exclusion spans. + if (owner == null || span.start <= owner.start) { owner = ( start: span.start, end: span.end, @@ -1504,6 +1732,9 @@ final class _MarkdownProseScanner { if (owner.imageCode case final code?) { _scanImageCode(code); } else if (owner.inlineBreak) { + if (htmlLineBreaks[owner.start] == owner.end) { + _scanHtmlBreakAttributes(owner.start, owner.end); + } _emit( ' ', owner.start, @@ -1550,6 +1781,14 @@ final class _MarkdownProseScanner { breakStart + lineBreak.lineEnding.length + lineBreak.continuationPrefix.length; + if (!_inImageLabel && + htmlFragments.any( + (span) => span.start <= breakStart && span.end > breakStart, + )) { + if (cursor < breakStart) _scanInline(cursor, breakStart); + cursor = nextStart; + continue; + } _SourceInterval? hardBreak; for (final span in hardBreakSyntax) { if (span.start >= cursor && @@ -2081,8 +2320,6 @@ bool _commonMarkEscapableAsciiPunctuation(int unit) => (unit >= 0x5b && unit <= 0x60) || (unit >= 0x7b && unit <= 0x7e); -bool _horizontalWhitespace(int unit) => unit == 0x20 || unit == 0x09; - bool _coversSourceRange(int start, int end, List<_SourceInterval> intervals) { if (start >= end) return true; final ordered = [...intervals] @@ -2097,19 +2334,10 @@ bool _coversSourceRange(int start, int end, List<_SourceInterval> intervals) { return false; } -bool _isSetextOrThematic(String line) { - final trimmed = line.trim(); - return RegExp(r'^(?:=+|-+)$').hasMatch(trimmed) || - RegExp(r'^(?:\*\s*){3,}$').hasMatch(trimmed) || - RegExp(r'^(?:_\s*){3,}$').hasMatch(trimmed); -} - -final RegExp _blockPrefix = RegExp( - r'^[ \t]{0,3}(?:(?:>[ \t]?)+)?(?:(?:[-+*]|\d+[.)])[ \t]+(?:\[[ xX]\][ \t]+)?)?', -); final RegExp _entity = RegExp( r'&(?:#[0-9]{1,7}|#[xX][0-9A-Fa-f]{1,6}|[A-Za-z][A-Za-z0-9]{1,31});', ); +final RegExp _htmlWhitespaceText = RegExp(r'^\s+$'); final RegExp _autolink = RegExp(r'^<[A-Za-z][A-Za-z0-9+.-]*:[^ <>]*>$'); final RegExp _emailAutolink = RegExp(r'^<[^ <>@]+@[^ <>@]+>$'); final RegExp _humanAttribute = RegExp( diff --git a/lib/src/workspace/presentation/workspace_screen.dart b/lib/src/workspace/presentation/workspace_screen.dart index b3dc73ca..7aa58be1 100644 --- a/lib/src/workspace/presentation/workspace_screen.dart +++ b/lib/src/workspace/presentation/workspace_screen.dart @@ -118,6 +118,7 @@ import '../workspace_model.dart'; import '../workspace_message.dart'; import '../workspace_safety.dart'; import '../workspace_tabs.dart'; +import '../workspace_tab_actions.dart'; import 'welcome_screen.dart'; import 'writerside_instance_dialog.dart'; import 'writerside_markdown_import_dialog.dart'; @@ -680,6 +681,135 @@ class _SearchReturnFocus { class WorkspaceScreen extends ConsumerWidget { const WorkspaceScreen({super.key}); + Future _showTabMenu( + BuildContext context, + WidgetRef ref, + Workspace workspace, + WorkspaceTabEntry entry, + Offset position, + ) async { + if (!isWorkspaceTabCurrent(ref, workspace, entry)) return; + final state = ref.read(workspaceControllerProvider); + final buffer = state.documentBuffers + .where((buffer) => buffer.id == entry.bufferId) + .firstOrNull; + final history = ref.read(localHistoryControllerProvider); + final entries = workspaceTabEntries( + workspace: state.workspace!, + gitState: ref.read(gitControllerProvider), + documentBuffers: state.documentBuffers, + activeBufferId: state.activeBufferId, + localHistoryRevisionId: history.selectedRevisionId, + localHistoryDocumentName: history.selectedDocument?.displayName, + ); + final action = await showBusyMarkContextMenu<_WorkspaceTabAction>( + context, + position, + items: [ + BusyMarkPopupMenuItem( + value: _WorkspaceTabAction.close, + label: context.l10n.close, + icon: BusyMarkGlyphs.clear, + ), + if (entry.kind == WorkspaceTabKind.file) ...[ + BusyMarkPopupMenuItem( + value: _WorkspaceTabAction.closeOthers, + label: context.l10n.closeOtherTabs, + icon: BusyMarkGlyphs.clear, + enabled: entries.any((tab) => tab.key != entry.key), + ), + BusyMarkPopupMenuItem( + value: _WorkspaceTabAction.closeAll, + label: context.l10n.shortcutCloseAllTabs, + icon: BusyMarkGlyphs.clear, + ), + const PopupMenuDivider(height: BusyMarkSpacing.sm), + BusyMarkPopupMenuItem( + value: _WorkspaceTabAction.copyPath, + label: context.l10n.copyPath, + icon: BusyMarkGlyphs.copy, + enabled: + buffer != null && + !buffer.isRemote && + buffer.filePath != null && + buffer.filePath!.isNotEmpty, + ), + const PopupMenuDivider(height: BusyMarkSpacing.sm), + BusyMarkPopupMenuItem( + value: _WorkspaceTabAction.localHistory, + label: context.l10n.localHistoryEllipsis, + icon: BusyMarkGlyphs.documentHistory, + ), + ], + ], + ); + if (action == null || + !context.mounted || + !isWorkspaceTabCurrent(ref, workspace, entry)) { + return; + } + final actionWorkspace = ref.read(workspaceControllerProvider).workspace!; + switch (action) { + case _WorkspaceTabAction.localHistory: + final requestedBuffer = ref + .read(workspaceControllerProvider) + .documentBuffers + .where((candidate) => candidate.id == entry.bufferId) + .firstOrNull; + if (requestedBuffer == null) return; + final controller = ref.read(workspaceControllerProvider.notifier); + final historyController = ref.read( + localHistoryControllerProvider.notifier, + ); + if (!await controller.activateDocumentBuffer(requestedBuffer.id) || + !context.mounted || + !isWorkspaceTabCurrent(ref, actionWorkspace, entry)) { + return; + } + final currentRequestedBuffer = ref + .read(workspaceControllerProvider) + .documentBuffers + .where((candidate) => candidate.id == requestedBuffer.id) + .firstOrNull; + if (currentRequestedBuffer == null) return; + await historyController.selectDocumentForBuffer(currentRequestedBuffer); + if (!context.mounted || !ref.context.mounted) return; + ref + .read(_sidebarShortcutRequestProvider.notifier) + .select(_SidebarTab.localHistory); + case _WorkspaceTabAction.close: + await closeWorkspaceTab( + context, + ref, + workspace: actionWorkspace, + tab: entry, + ); + case _WorkspaceTabAction.closeOthers: + await closeOtherWorkspaceTabs( + context, + ref, + workspace: actionWorkspace, + retainedTab: entry, + ); + case _WorkspaceTabAction.closeAll: + await closeAllWorkspaceTabs(context, ref, workspace: actionWorkspace); + case _WorkspaceTabAction.copyPath: + final current = ref + .read(workspaceControllerProvider) + .documentBuffers + .where((buffer) => buffer.id == entry.bufferId) + .firstOrNull; + final path = current?.filePath; + if (current == null || + current.isRemote || + path == null || + path.isEmpty) { + return; + } + await _copyToClipboard(path); + } + } + @override Widget build(BuildContext context, WidgetRef ref) { final state = ref.watch(workspaceControllerProvider); @@ -721,6 +851,11 @@ class WorkspaceScreen extends ConsumerWidget { state: state, gitState: gitState, localHistoryState: localHistoryState, + onClose: (entry) => unawaited( + closeWorkspaceTab(context, ref, workspace: workspace, tab: entry), + ), + onShowMenu: (entry, position) => + _showTabMenu(context, ref, workspace, entry, position), ), Expanded( child: localHistoryState.selectedRevision != null @@ -2469,6 +2604,7 @@ class _SidebarState extends ConsumerState<_Sidebar> { _WritersideTopicRemovalTarget target, { bool updateUsagesAutomatically = true, String? redirectTopicPath, + String? homePageTopicPath, bool applyIfUnused = false, }) async { if (!await confirmSafeToRefactorWritersideProject(context, ref) || @@ -2493,6 +2629,9 @@ class _SidebarState extends ConsumerState<_Sidebar> { final initialRedirect = analysis.redirectTargets .where((candidate) => candidate.topicPath == redirectTopicPath) .firstOrNull; + final initialHomePage = analysis.homePageReplacementForTopic( + homePageTopicPath, + ); _WritersideTopicRemovalDialogResult decision; if (applyIfUnused && analysis.relevantUsages.isEmpty) { decision = _WritersideTopicRemovalDialogResult.apply( @@ -2510,6 +2649,7 @@ class _SidebarState extends ConsumerState<_Sidebar> { analysis: analysis, initialUpdateUsagesAutomatically: updateUsagesAutomatically, initialRedirectTarget: initialRedirect, + initialHomePageReplacement: initialHomePage, ), ); if (!mounted || !context.mounted || selected == null) { @@ -2524,6 +2664,7 @@ class _SidebarState extends ConsumerState<_Sidebar> { analysis: analysis, updateUsagesAutomatically: decision.updateUsagesAutomatically, redirectTopicPath: decision.redirectTarget?.topicPath, + homePageTopicPath: decision.homePageReplacement?.topicPath, ); }); return null; @@ -2542,6 +2683,7 @@ class _SidebarState extends ConsumerState<_Sidebar> { target, updateUsagesAutomatically: decision.updateUsagesAutomatically, redirectTopicPath: decision.redirectTarget?.topicPath, + homePageTopicPath: decision.homePageReplacement?.topicPath, applyIfUnused: applyIfUnused, ); } @@ -2550,6 +2692,7 @@ class _SidebarState extends ConsumerState<_Sidebar> { analysis: analysis, updateUsagesAutomatically: decision.updateUsagesAutomatically, redirectTarget: decision.redirectTarget, + homePageReplacement: decision.homePageReplacement, ), ); if (!mounted || !context.mounted) { @@ -2607,6 +2750,19 @@ class _SidebarState extends ConsumerState<_Sidebar> { _showLatestWorkspaceMessage(context); return; } + final homePage = analysis.homePageReplacementForTopic( + review.homePageTopicPath, + ); + if (analysis.requiresHomePageReplacement && homePage == null) { + await _runWritersideTopicRemoval( + context, + review.target, + updateUsagesAutomatically: review.updateUsagesAutomatically, + redirectTopicPath: review.redirectTopicPath, + homePageTopicPath: review.homePageTopicPath, + ); + return; + } final redirect = review.redirectTopicPath == null ? null : analysis.redirectTargets @@ -2622,16 +2778,20 @@ class _SidebarState extends ConsumerState<_Sidebar> { ); return; } - if (analysis.blockingUsages.any( - (usage) => - !review.updateUsagesAutomatically || !usage.canUpdateAutomatically, - )) { + if (analysis + .unresolvedBlockingUsages(homePage) + .any( + (usage) => + !review.updateUsagesAutomatically || + !usage.canUpdateAutomatically, + )) { setState(() { _topicUsageReview = _WritersideTopicUsageReview( target: review.target, analysis: analysis, updateUsagesAutomatically: review.updateUsagesAutomatically, redirectTopicPath: review.redirectTopicPath, + homePageTopicPath: homePage?.topicPath, ); }); return; @@ -2641,6 +2801,7 @@ class _SidebarState extends ConsumerState<_Sidebar> { analysis: analysis, updateUsagesAutomatically: review.updateUsagesAutomatically, redirectTarget: redirect, + homePageReplacement: homePage, ), ); if (!mounted || !context.mounted) return; @@ -3689,29 +3850,35 @@ class _WritersideTopicRemovalDialogResult { required this.reviewUsages, required this.updateUsagesAutomatically, required this.redirectTarget, + required this.homePageReplacement, }); const _WritersideTopicRemovalDialogResult.review({ required bool updateUsagesAutomatically, required WritersideTopicRedirectTarget? redirectTarget, + WritersideHomePageReplacement? homePageReplacement, }) : this._( reviewUsages: true, updateUsagesAutomatically: updateUsagesAutomatically, redirectTarget: redirectTarget, + homePageReplacement: homePageReplacement, ); const _WritersideTopicRemovalDialogResult.apply({ required bool updateUsagesAutomatically, required WritersideTopicRedirectTarget? redirectTarget, + WritersideHomePageReplacement? homePageReplacement, }) : this._( reviewUsages: false, updateUsagesAutomatically: updateUsagesAutomatically, redirectTarget: redirectTarget, + homePageReplacement: homePageReplacement, ); final bool reviewUsages; final bool updateUsagesAutomatically; final WritersideTopicRedirectTarget? redirectTarget; + final WritersideHomePageReplacement? homePageReplacement; } class _WritersideTopicUsageReview { @@ -3720,12 +3887,14 @@ class _WritersideTopicUsageReview { required this.analysis, required this.updateUsagesAutomatically, required this.redirectTopicPath, + required this.homePageTopicPath, }); final _WritersideTopicRemovalTarget target; final WritersideTopicRemovalAnalysis analysis; final bool updateUsagesAutomatically; final String? redirectTopicPath; + final String? homePageTopicPath; } class _WritersideTopicRemovalDialog extends StatefulWidget { @@ -3733,11 +3902,13 @@ class _WritersideTopicRemovalDialog extends StatefulWidget { required this.analysis, required this.initialUpdateUsagesAutomatically, required this.initialRedirectTarget, + required this.initialHomePageReplacement, }); final WritersideTopicRemovalAnalysis analysis; final bool initialUpdateUsagesAutomatically; final WritersideTopicRedirectTarget? initialRedirectTarget; + final WritersideHomePageReplacement? initialHomePageReplacement; @override State<_WritersideTopicRemovalDialog> createState() => @@ -3748,14 +3919,15 @@ class _WritersideTopicRemovalDialogState extends State<_WritersideTopicRemovalDialog> { late bool _updateUsagesAutomatically; WritersideTopicRedirectTarget? _redirectTarget; + WritersideHomePageReplacement? _homePageReplacement; WritersideTopicRemovalAnalysis get _analysis => widget.analysis; bool get _canApply { - if (_analysis.blockingUsages.isEmpty) { - return true; - } - return _updateUsagesAutomatically && _analysis.canUpdateUsagesAutomatically; + final blocking = _analysis.unresolvedBlockingUsages(_homePageReplacement); + return blocking.isEmpty || + (_updateUsagesAutomatically && + blocking.every((usage) => usage.canUpdateAutomatically)); } @override @@ -3763,8 +3935,9 @@ class _WritersideTopicRemovalDialogState super.initState(); _updateUsagesAutomatically = widget.initialUpdateUsagesAutomatically && - _analysis.canUpdateUsagesAutomatically; + _analysis.canUpdateOtherUsagesAutomatically; _redirectTarget = widget.initialRedirectTarget; + _homePageReplacement = widget.initialHomePageReplacement; } @override @@ -3795,6 +3968,7 @@ class _WritersideTopicRemovalDialogState _WritersideTopicRemovalDialogResult.review( updateUsagesAutomatically: _updateUsagesAutomatically, redirectTarget: _redirectTarget, + homePageReplacement: _homePageReplacement, ), ), ), @@ -3812,6 +3986,7 @@ class _WritersideTopicRemovalDialogState _WritersideTopicRemovalDialogResult.apply( updateUsagesAutomatically: _updateUsagesAutomatically, redirectTarget: _redirectTarget, + homePageReplacement: _homePageReplacement, ), ) : null, @@ -3833,10 +4008,45 @@ class _WritersideTopicRemovalDialogState if (_analysis.isStartPage) ...[ const SizedBox(height: BusyMarkSpacing.md), BusyMarkStatusBox( - message: context.l10n.topicIsStartPageRemovalWarning, + message: removeFromInstance + ? context.l10n.topicIsHomePageRemovalWarning + : context.l10n.topicIsStartPageRemovalWarning, kind: BusyMarkStatusKind.warning, ), ], + if (_analysis.requiresHomePageReplacement) ...[ + const SizedBox(height: BusyMarkSpacing.md), + if (_analysis.homePageReplacements.isEmpty) + BusyMarkStatusBox( + message: context.l10n.noHomePageReplacement, + kind: BusyMarkStatusKind.warning, + ) + else + Row( + children: [ + Text(context.l10n.newHomePage), + const SizedBox(width: BusyMarkSpacing.sm), + Expanded( + child: BusyMarkPopupSelector( + value: _homePageReplacement, + label: + _homePageReplacement?.label ?? + context.l10n.chooseHomePage, + tooltip: context.l10n.newHomePage, + options: [ + for (final candidate in _analysis.homePageReplacements) + BusyMarkPopupSelectorOption( + value: candidate, + label: candidate.label, + ), + ], + onSelected: (value) => + setState(() => _homePageReplacement = value), + ), + ), + ], + ), + ], if (removeFromInstance) Row( children: [ @@ -3880,7 +4090,7 @@ class _WritersideTopicRemovalDialogState children: [ BusyMarkCheckbox( value: _updateUsagesAutomatically, - onChanged: _analysis.canUpdateUsagesAutomatically + onChanged: _analysis.canUpdateOtherUsagesAutomatically ? (value) => setState( () => _updateUsagesAutomatically = value ?? false, ) @@ -10260,11 +10470,15 @@ class _EditorTabStrip extends ConsumerWidget { required this.state, required this.gitState, required this.localHistoryState, + required this.onClose, + required this.onShowMenu, }); final WorkspaceState state; final GitState gitState; final LocalHistoryState localHistoryState; + final void Function(WorkspaceTabEntry) onClose; + final void Function(WorkspaceTabEntry, Offset) onShowMenu; @override Widget build(BuildContext context, WidgetRef ref) { @@ -10306,6 +10520,7 @@ class _EditorTabStrip extends ConsumerWidget { itemBuilder: (context, index) { final entry = entries[index]; return _WorkspaceTabButton( + key: ValueKey(entry.key), title: _tabTitle(context, workspace, entry), icon: _tabIcon(workspace, entry), diff: entry.kind == WorkspaceTabKind.gitDiff, @@ -10313,14 +10528,9 @@ class _EditorTabStrip extends ConsumerWidget { dirty: _tabDirty(workspace, entry), onSelected: () => _selectTab(context, ref, workspace, entry), - onClose: () => _closeTab(context, ref, workspace, entry), - onSecondaryTapUp: (details) => _showTabMenu( - context, - ref, - workspace, - entry, - details.globalPosition, - ), + onClose: () => onClose(entry), + onSecondaryTapUp: (details) => + onShowMenu(entry, details.globalPosition), ); }, separatorBuilder: (context, index) => @@ -10418,100 +10628,19 @@ class _EditorTabStrip extends ConsumerWidget { return; } } - - Future _closeTab( - BuildContext context, - WidgetRef ref, - Workspace workspace, - WorkspaceTabEntry entry, - ) async { - final gitController = ref.read(gitControllerProvider.notifier); - switch (entry.kind) { - case WorkspaceTabKind.file: - final controller = ref.read(workspaceControllerProvider.notifier); - if (entry.dirty) { - if (entry.bufferId != state.activeBufferId) { - await controller.activateDocumentBuffer(entry.bufferId!); - } - if (!context.mounted || - !await confirmSafeToCloseActiveDocument(context, ref) || - !context.mounted) { - return; - } - } - await controller.closeDocumentBuffer(entry.bufferId!); - gitController.deactivateDiffFile(); - case WorkspaceTabKind.gitDiff: - if (entry.path.isEmpty) { - gitController.clearSelection(); - } else { - gitController.closeDiffFile(entry.path); - } - case WorkspaceTabKind.localHistory: - ref.read(localHistoryControllerProvider.notifier).clearComparison(); - } - } - - Future _showTabMenu( - BuildContext context, - WidgetRef ref, - Workspace workspace, - WorkspaceTabEntry entry, - Offset position, - ) async { - final action = await showBusyMarkContextMenu<_WorkspaceTabAction>( - context, - position, - items: [ - if (entry.kind == WorkspaceTabKind.file) - BusyMarkPopupMenuItem( - value: _WorkspaceTabAction.localHistory, - label: context.l10n.localHistoryEllipsis, - icon: BusyMarkGlyphs.documentHistory, - ), - if (entry.kind == WorkspaceTabKind.file) - const PopupMenuDivider(height: BusyMarkSpacing.sm), - BusyMarkPopupMenuItem( - value: _WorkspaceTabAction.close, - label: MaterialLocalizations.of(context).closeButtonTooltip, - icon: BusyMarkGlyphs.clear, - ), - ], - ); - if (action == null || !context.mounted) return; - switch (action) { - case _WorkspaceTabAction.localHistory: - final requestedBuffer = state.documentBuffers - .where((candidate) => candidate.id == entry.bufferId) - .firstOrNull; - if (requestedBuffer == null) return; - final activated = await ref - .read(workspaceControllerProvider.notifier) - .activateDocumentBuffer(requestedBuffer.id); - if (!activated || !context.mounted) return; - final currentRequestedBuffer = ref - .read(workspaceControllerProvider) - .documentBuffers - .where((candidate) => candidate.id == requestedBuffer.id) - .firstOrNull; - if (currentRequestedBuffer == null) return; - await ref - .read(localHistoryControllerProvider.notifier) - .selectDocumentForBuffer(currentRequestedBuffer); - if (!context.mounted) return; - ref - .read(_sidebarShortcutRequestProvider.notifier) - .select(_SidebarTab.localHistory); - case _WorkspaceTabAction.close: - await _closeTab(context, ref, workspace, entry); - } - } } -enum _WorkspaceTabAction { localHistory, close } +enum _WorkspaceTabAction { + close, + closeOthers, + closeAll, + copyPath, + localHistory, +} class _WorkspaceTabButton extends StatelessWidget { const _WorkspaceTabButton({ + super.key, required this.title, required this.icon, required this.diff, @@ -10544,66 +10673,69 @@ class _WorkspaceTabButton extends StatelessWidget { borderRadius: const BorderRadius.vertical( top: Radius.circular(BusyMarkRadius.sm), ), - child: InkWell( - borderRadius: const BorderRadius.vertical( - top: Radius.circular(BusyMarkRadius.sm), - ), - hoverColor: colors.controlHover, - onTap: onSelected, - onSecondaryTapUp: onSecondaryTapUp, - child: Container( - height: BusyMarkSizes.paneHeaderHeight - BusyMarkSpacing.xs, - constraints: const BoxConstraints(minWidth: 112, maxWidth: 240), - decoration: BoxDecoration( - borderRadius: const BorderRadius.vertical( - top: Radius.circular(BusyMarkRadius.sm), - ), - border: Border.all(color: borderColor), - ), - padding: const EdgeInsetsDirectional.only( - start: BusyMarkSpacing.sm, - end: BusyMarkSpacing.xs, + child: GestureDetector( + onTertiaryTapUp: (_) => onClose(), + child: InkWell( + borderRadius: const BorderRadius.vertical( + top: Radius.circular(BusyMarkRadius.sm), ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (dirty) ...[ - Container( - width: BusyMarkSizes.markerDot, - height: BusyMarkSizes.markerDot, - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.primary, - shape: BoxShape.circle, + hoverColor: colors.controlHover, + onTap: onSelected, + onSecondaryTapUp: onSecondaryTapUp, + child: Container( + height: BusyMarkSizes.paneHeaderHeight - BusyMarkSpacing.xs, + constraints: const BoxConstraints(minWidth: 112, maxWidth: 240), + decoration: BoxDecoration( + borderRadius: const BorderRadius.vertical( + top: Radius.circular(BusyMarkRadius.sm), + ), + border: Border.all(color: borderColor), + ), + padding: const EdgeInsetsDirectional.only( + start: BusyMarkSpacing.sm, + end: BusyMarkSpacing.xs, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (dirty) ...[ + Container( + width: BusyMarkSizes.markerDot, + height: BusyMarkSizes.markerDot, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary, + shape: BoxShape.circle, + ), ), - ), - const SizedBox(width: BusyMarkSpacing.sm), - ] else ...[ - if (diff) - _DiffCompareIcon(color: foreground) - else - Icon(icon, size: BusyMarkSizes.iconSm, color: foreground), - const SizedBox(width: BusyMarkSpacing.sm), - ], - Flexible( - child: Text( - title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - softWrap: false, - style: Theme.of(context).textTheme.labelMedium?.copyWith( - color: foreground, - fontWeight: active ? FontWeight.w700 : FontWeight.w500, + const SizedBox(width: BusyMarkSpacing.sm), + ] else ...[ + if (diff) + _DiffCompareIcon(color: foreground) + else + Icon(icon, size: BusyMarkSizes.iconSm, color: foreground), + const SizedBox(width: BusyMarkSpacing.sm), + ], + Flexible( + child: Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + softWrap: false, + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: foreground, + fontWeight: active ? FontWeight.w700 : FontWeight.w500, + ), ), ), - ), - const SizedBox(width: BusyMarkSpacing.xs), - BusyMarkCompactIconButton( - tooltip: MaterialLocalizations.of(context).closeButtonTooltip, - icon: BusyMarkGlyphs.clear, - foregroundColor: foreground, - onPressed: onClose, - ), - ], + const SizedBox(width: BusyMarkSpacing.xs), + BusyMarkCompactIconButton( + tooltip: MaterialLocalizations.of(context).closeButtonTooltip, + icon: BusyMarkGlyphs.clear, + foregroundColor: foreground, + onPressed: onClose, + ), + ], + ), ), ), ), diff --git a/lib/src/workspace/workspace_controller.dart b/lib/src/workspace/workspace_controller.dart index 384473a0..4bbdb20e 100644 --- a/lib/src/workspace/workspace_controller.dart +++ b/lib/src/workspace/workspace_controller.dart @@ -452,6 +452,14 @@ class WorkspaceController extends Notifier { await repository.initialize(); if (!ref.mounted) throw StateError('Workspace closed'); _notesRepository = repository; + bool bindingGuard(String localId, Set ids) => + !state.documentBuffers.any( + (b) => + ids.contains(b.remoteNote?.localId) || + (b.remoteNote?.localId == localId && b.isDirty), + ); + repository.addCreationBindingGuard(bindingGuard); + ref.onDispose(() => repository.removeCreationBindingGuard(bindingGuard)); _notesSubscription = repository.changes.listen((_) => _notesChanged()); return repository; } @@ -681,7 +689,9 @@ class WorkspaceController extends Notifier { final repository = await _ensureNotesRepository(); final note = repository.noteById(localId); final workspace = state.workspace; - if (note == null || workspace?.nextcloudAccountId != note.accountId) { + if (note == null || + repository.isCreationCandidateBinding(localId) || + workspace?.nextcloudAccountId != note.accountId) { return false; } final existing = state.documentBuffers @@ -1005,9 +1015,28 @@ class WorkspaceController extends Notifier { Map metadataChoices = const {}, int? expectedRevision, int? creationCandidateServerId, + NotesCreationReview? creationReview, }) async { try { final repository = await _ensureNotesRepository(); + if (resolution == NoteConflictResolution.useServerNote) { + final candidateIds = repository.notes + .where( + (n) => + n.accountId == creationReview?.accountId && + n.serverId == creationCandidateServerId, + ) + .map((n) => n.localId) + .toSet(); + if (state.documentBuffers.any( + (b) => candidateIds.contains(b.remoteNote?.localId), + )) { + throw const NotesException( + NotesFailureCode.conflict, + 'Close the downloaded candidate’s editor tabs after preserving their changes, then review again.', + ); + } + } final buffer = state.documentBuffers .where((b) => b.remoteNote?.localId == localId) .firstOrNull; @@ -1037,6 +1066,7 @@ class WorkspaceController extends Notifier { metadataChoices: metadataChoices, expectedRevision: expectedRevision, creationCandidateServerId: creationCandidateServerId, + creationReview: creationReview, ); final accountId = repository.noteById(localId)?.accountId; if (accountId != null) _scheduleRemoteSync(accountId); @@ -4917,12 +4947,18 @@ class WorkspaceController extends Notifier { if (candidate.id != bufferId) candidate, ]; if (remaining.isEmpty) { - _removalOperationRevision++; - state = workspace.isRemote - ? WorkspaceState(workspace: workspace.copyWith(markdown: null)) - : const WorkspaceState(); - _fileMonitor.updateOpenFilePaths(const []); - _schedulePersistence(); + if (_supportsOpenFileTabs(workspace)) { + // The last buffer can be an untitled draft in a folder/project. + // Discarding that draft closes a view and retains the workspace. + await _clearOpenFileTabs(workspace); + } else { + _removalOperationRevision++; + state = workspace.isRemote + ? WorkspaceState(workspace: workspace.copyWith(markdown: null)) + : const WorkspaceState(); + _fileMonitor.updateOpenFilePaths(const []); + _schedulePersistence(); + } await _localHistory.handleBufferClosed( bufferId, historySettled: historySettled, diff --git a/lib/src/workspace/workspace_safety.dart b/lib/src/workspace/workspace_safety.dart index b03969aa..0efaf568 100644 --- a/lib/src/workspace/workspace_safety.dart +++ b/lib/src/workspace/workspace_safety.dart @@ -99,6 +99,24 @@ Future confirmSafeToCloseActiveDocument( return _confirmUnsavedChanges(context, ref, dirtyBufferIds: [active.id]); } +/// Resolves only dirty documents in the requested close set, including +/// inactive buffers, without prompting for a retained document. +Future confirmSafeToCloseDocumentBuffers( + BuildContext context, + WidgetRef ref, + Iterable bufferIds, +) { + final requested = bufferIds.toSet(); + return _confirmUnsavedChanges( + context, + ref, + dirtyBufferIds: [ + for (final buffer in ref.read(workspaceControllerProvider).dirtyBuffers) + if (requested.contains(buffer.id)) buffer.id, + ], + ); +} + Future _confirmUnsavedChanges( BuildContext context, WidgetRef ref, { @@ -175,6 +193,7 @@ Future _confirmUnsavedChanges( ), ); + if (!context.mounted || !ref.context.mounted) return false; final currentState = ref.read(workspaceControllerProvider); final currentDirtyIds = currentState.dirtyBuffers .map((buffer) => buffer.id) diff --git a/lib/src/workspace/workspace_tab_actions.dart b/lib/src/workspace/workspace_tab_actions.dart new file mode 100644 index 00000000..857472f4 --- /dev/null +++ b/lib/src/workspace/workspace_tab_actions.dart @@ -0,0 +1,271 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../git/application/git_controller.dart'; +import '../local_history/local_history_controller.dart'; +import 'workspace_controller.dart'; +import 'workspace_model.dart'; +import 'workspace_safety.dart'; +import 'workspace_tabs.dart'; + +// All tab-closing entry points share this guard, including keyboard commands. +// The controller is the workspace owner's identity, independent of tab widgets. +final _closing = Expando(); + +List _tabs(WidgetRef ref, WorkspaceState state) { + final workspace = state.workspace; + if (workspace == null) return const []; + final history = ref.read(localHistoryControllerProvider); + return workspaceTabEntries( + workspace: workspace, + gitState: ref.read(gitControllerProvider), + documentBuffers: state.documentBuffers, + activeBufferId: state.activeBufferId, + localHistoryRevisionId: history.selectedRevisionId, + localHistoryDocumentName: history.selectedDocument?.displayName, + ); +} + +bool _sameWorkspace(Workspace workspace, WorkspaceState state) => + state.workspace?.id == workspace.id && + state.workspace?.openedAt == workspace.openedAt; + +bool _tabSurvivedFirstSave( + Workspace workspace, + WorkspaceState state, + WorkspaceTabEntry tab, +) => + workspace.kind == WorkspaceKind.untitledMarkdown && + state.workspace?.kind == WorkspaceKind.singleMarkdown && + tab.kind == WorkspaceTabKind.file && + tab.path.isEmpty && + state.documentBuffers.any( + (buffer) => + buffer.id == tab.bufferId && + buffer.filePath != null && + buffer.filePath!.isNotEmpty, + ); + +/// Validates a tab captured before an asynchronous menu was presented. +bool isWorkspaceTabCurrent( + WidgetRef ref, + Workspace workspace, + WorkspaceTabEntry tab, +) { + if (!ref.context.mounted) return false; + final state = ref.read(workspaceControllerProvider); + return (_sameWorkspace(workspace, state) || + _tabSurvivedFirstSave(workspace, state, tab)) && + _tabs(ref, state).any((candidate) => candidate.key == tab.key); +} + +/// Closes the specified view without selecting it before confirmation. +Future closeWorkspaceTab( + BuildContext context, + WidgetRef ref, { + required Workspace workspace, + required WorkspaceTabEntry tab, +}) => _closeTabs(context, ref, workspace: workspace, clicked: tab); + +/// Keeps the specified document and its editor session, then activates it. +Future closeOtherWorkspaceTabs( + BuildContext context, + WidgetRef ref, { + required Workspace workspace, + required WorkspaceTabEntry retainedTab, +}) => retainedTab.kind != WorkspaceTabKind.file + ? Future.value(false) + : _closeTabs( + context, + ref, + workspace: workspace, + clicked: retainedTab, + retain: true, + ); + +/// Closes captured views while preserving the controller's workspace lifecycle. +Future closeAllWorkspaceTabs( + BuildContext context, + WidgetRef ref, { + required Workspace workspace, +}) => _closeTabs(context, ref, workspace: workspace); + +Future _closeTabs( + BuildContext context, + WidgetRef ref, { + required Workspace workspace, + WorkspaceTabEntry? clicked, + bool retain = false, +}) async { + if (!context.mounted || !ref.context.mounted) return false; + final controller = ref.read(workspaceControllerProvider.notifier); + if (_closing[controller] == true) return false; + final initial = ref.read(workspaceControllerProvider); + if (!_sameWorkspace(workspace, initial) && + (clicked == null || + !_tabSurvivedFirstSave(workspace, initial, clicked))) { + return false; + } + final tabs = _tabs(ref, initial); + if (clicked != null && !tabs.any((tab) => tab.key == clicked.key)) { + return false; + } + final targets = [ + for (final tab in tabs) + if (clicked == null || + (retain ? tab.key != clicked.key : tab.key == clicked.key)) + tab, + ]; + if (targets.isEmpty) return false; + final bufferIds = { + for (final tab in targets) + if (tab.kind == WorkspaceTabKind.file) tab.bufferId!, + }; + // Capture controllers before destructive awaits. Callers must supply a ref + // owned by WorkspaceScreen or the app, never the disappearing tab strip. + final git = ref.read(gitControllerProvider.notifier); + final history = ref.read(localHistoryControllerProvider.notifier); + var operationWorkspace = initial.workspace!; + var documentsFinished = false; + bool currentWorkspace() { + if (!context.mounted || !ref.context.mounted) return false; + final live = ref.read(workspaceControllerProvider); + if (retain) { + if (!live.documentBuffers.any( + (buffer) => buffer.id == clicked!.bufferId, + )) { + return false; + } + final revision = ref + .read(localHistoryControllerProvider) + .selectedRevisionId; + // A new comparison cannot be cleared merely to activate the retained + // document. Stop if it replaced the captured comparison during a wait. + if (revision != null && + !targets.any( + (tab) => + tab.kind == WorkspaceTabKind.localHistory && + tab.bufferId == revision, + )) { + return false; + } + } + final current = live.workspace; + if (current?.id == operationWorkspace.id && + current?.openedAt == operationWorkspace.openedAt) { + return true; + } + // First Save As replaces an untitled workspace, preserving buffer IDs. + // Require a surviving original draft that acquired a local path. + if (operationWorkspace.kind == WorkspaceKind.untitledMarkdown && + current?.kind == WorkspaceKind.singleMarkdown && + initial.documentBuffers.any( + (original) => + original.isUntitled && + live.documentBuffers.any( + (buffer) => buffer.id == original.id && buffer.filePath != null, + ), + )) { + operationWorkspace = current!; + return true; + } + // Authorized discard/closing of the last pathless local buffer can return + // to Welcome. There is no document removal left to perform in that case. + return current == null && + operationWorkspace.kind == WorkspaceKind.untitledMarkdown && + live.documentBuffers.isEmpty; + } + + _closing[controller] = true; + try { + if (!await confirmSafeToCloseDocumentBuffers(context, ref, bufferIds) || + !currentWorkspace()) { + return false; + } + var live = ref.read(workspaceControllerProvider); + if (live.documentBuffers.any( + (buffer) => bufferIds.contains(buffer.id) && buffer.isDirty, + )) { + return false; + } + if (clicked == null) { + // The local bulk API owns history flushing and folder/project empty + // state. It must never see documents opened after target capture. + if (live.documentBuffers.any( + (buffer) => !bufferIds.contains(buffer.id), + )) { + return false; + } + if (live.documentBuffers.isNotEmpty && + switch (live.workspace!.kind) { + WorkspaceKind.singleMarkdown || + WorkspaceKind.markdownFolder || + WorkspaceKind.writersideModule => true, + WorkspaceKind.untitledMarkdown || + WorkspaceKind.nextcloudNotes => false, + }) { + if (!await controller.closeAllOpenFileTabs() || !currentWorkspace()) { + return false; + } + documentsFinished = true; + } + } + if (!documentsFinished) { + for (final id in bufferIds) { + if (!currentWorkspace()) return false; + live = ref.read(workspaceControllerProvider); + final buffer = live.documentBuffers + .where((buffer) => buffer.id == id) + .firstOrNull; + // Discard can already have removed an untitled/deleted buffer. + if (buffer == null) continue; + if (buffer.isDirty) return false; + final closed = await controller.closeDocumentBuffer(id); + if (!currentWorkspace()) return false; + if (!closed && + ref + .read(workspaceControllerProvider) + .documentBuffers + .any((buffer) => buffer.id == id)) { + return false; + } + } + } + if (!currentWorkspace()) return false; + final gitTargets = targets + .where((tab) => tab.kind == WorkspaceTabKind.gitDiff) + .toList(); + final liveGit = ref.read(gitControllerProvider); + if (gitTargets.isNotEmpty) { + final paths = gitTargets.map((tab) => tab.path).toSet(); + if (liveGit.openDiffFilePaths.every(paths.contains) && + (liveGit.openDiffFilePaths.isNotEmpty || paths.contains(''))) { + git.clearSelection(); + } else { + for (final path in paths) { + if (path.isNotEmpty) git.closeDiffFile(path); + } + } + } + final revisionId = ref + .read(localHistoryControllerProvider) + .selectedRevisionId; + if (targets.any( + (tab) => + tab.kind == WorkspaceTabKind.localHistory && + tab.bufferId == revisionId, + )) { + history.clearComparison(); + } + if (retain) { + if (!await controller.activateDocumentBuffer(clicked!.bufferId!) || + !currentWorkspace()) { + return false; + } + git.deactivateDiffFile(); + } + return true; + } finally { + _closing[controller] = false; + } +} diff --git a/lib/src/writerside/writerside_topic_removal_service.dart b/lib/src/writerside/writerside_topic_removal_service.dart index 85ce73a8..d82552d9 100644 --- a/lib/src/writerside/writerside_topic_removal_service.dart +++ b/lib/src/writerside/writerside_topic_removal_service.dart @@ -94,6 +94,21 @@ class WritersideTopicRedirectTarget { final List nodePath; } +/// Identified by topic and instance, never by a positional TOC node path. +class WritersideHomePageReplacement { + const WritersideHomePageReplacement({ + required this.topicPath, + required this.treePath, + required this.topicReference, + required this.label, + }); + + final String topicPath; + final String treePath; + final String topicReference; + final String label; +} + class WritersideTopicRemovalAnalysis { WritersideTopicRemovalAnalysis({ required this.mode, @@ -112,6 +127,7 @@ class WritersideTopicRemovalAnalysis { required this.isStartPage, required List usages, required List redirectTargets, + List homePageReplacements = const [], required this.fingerprint, List projectModuleRoots = const [], }) : projectRoot = @@ -140,7 +156,8 @@ class WritersideTopicRemovalAnalysis { ? null : List.unmodifiable(selectedNodePath), usages = List.unmodifiable(usages), - redirectTargets = List.unmodifiable(redirectTargets); + redirectTargets = List.unmodifiable(redirectTargets), + homePageReplacements = List.unmodifiable(homePageReplacements); final WritersideTopicRemovalMode mode; final String projectRoot; @@ -159,6 +176,7 @@ class WritersideTopicRemovalAnalysis { final bool isStartPage; final List usages; final List redirectTargets; + final List homePageReplacements; final String fingerprint; List get relevantUsages => @@ -187,6 +205,51 @@ class WritersideTopicRemovalAnalysis { bool get canUpdateUsagesAutomatically => blockingUsages.every((usage) => usage.canUpdateAutomatically); + + bool get requiresHomePageReplacement => + mode == WritersideTopicRemovalMode.removeFromInstance && + selectedTreePath != null && + isStartPage; + + WritersideHomePageReplacement? homePageReplacementForTopic(String? path) => + path == null || !requiresHomePageReplacement + ? null + : homePageReplacements + .where((candidate) => p.equals(candidate.topicPath, path)) + .firstOrNull; + + bool resolvesHomePageUsage( + WritersideTopicUsage usage, + WritersideHomePageReplacement? replacement, + ) => + requiresHomePageReplacement && + replacement != null && + usage.relevant && + usage.kind == WritersideTopicUsageKind.startPage && + p.equals(usage.filePath, selectedTreePath!) && + homePageReplacements.any( + (candidate) => + p.equals(candidate.treePath, replacement.treePath) && + p.equals(candidate.topicPath, replacement.topicPath) && + candidate.topicReference == replacement.topicReference, + ); + + List unresolvedBlockingUsages( + WritersideHomePageReplacement? replacement, + ) => blockingUsages + .where((usage) => !resolvesHomePageUsage(usage, replacement)) + .toList(growable: false); + + // The checkbox controls link/include edits only. A pending explicit Home + // Page choice must not discard consent or disable those otherwise safe edits. + bool get canUpdateOtherUsagesAutomatically => blockingUsages.every( + (usage) => + usage.canUpdateAutomatically || + (requiresHomePageReplacement && + homePageReplacements.isNotEmpty && + usage.kind == WritersideTopicUsageKind.startPage && + p.equals(usage.filePath, selectedTreePath!)), + ); } class WritersideTopicRemovalRequest { @@ -194,11 +257,13 @@ class WritersideTopicRemovalRequest { required this.analysis, this.updateUsagesAutomatically = false, this.redirectTarget, + this.homePageReplacement, }); final WritersideTopicRemovalAnalysis analysis; final bool updateUsagesAutomatically; final WritersideTopicRedirectTarget? redirectTarget; + final WritersideHomePageReplacement? homePageReplacement; } class WritersideTopicRemovalResult { @@ -469,6 +534,11 @@ class WritersideTopicRemovalService { mode: mode, preferredTree: snapshot.trees[preferredTree]!, ), + homePageReplacements: + mode == WritersideTopicRemovalMode.removeFromInstance && + selectedTree != null + ? _homePageReplacements(snapshot, snapshot.trees[selectedTree]!) + : const [], fingerprint: snapshot.fingerprint, projectModuleRoots: snapshot.moduleRoots, ); @@ -505,10 +575,29 @@ class WritersideTopicRemovalService { args: {'topic': analysis.topicFileName}, ); } - final blocking = analysis.blockingUsages; + final requestedReplacement = request.homePageReplacement; + WritersideHomePageReplacement? replacement; + if (requestedReplacement != null) { + final tree = snapshot.trees[analysis.selectedTreePath]; + if (!analysis.requiresHomePageReplacement || tree == null) { + throw const BusyMarkException('writerside.toc.path-invalid'); + } + replacement = _homePageReplacements(snapshot, tree) + .where( + (candidate) => + p.equals(candidate.treePath, requestedReplacement.treePath) && + p.equals(candidate.topicPath, requestedReplacement.topicPath) && + candidate.topicReference == requestedReplacement.topicReference, + ) + .firstOrNull; + if (replacement == null) { + throw const BusyMarkException('writerside.toc.path-invalid'); + } + } + final blocking = analysis.unresolvedBlockingUsages(replacement); if (blocking.isNotEmpty && (!request.updateUsagesAutomatically || - !analysis.canUpdateUsagesAutomatically)) { + blocking.any((usage) => !usage.canUpdateAutomatically))) { throw BusyMarkException( 'writerside.topic-removal.usages-remain', args: {'path': analysis.topicPath}, @@ -523,6 +612,13 @@ class WritersideTopicRemovalService { for (final tree in snapshot.trees.values) tree.path: XmlDocument.parse(tree.source), }; + if (replacement != null) { + treeDocuments[replacement.treePath]!.rootElement.setAttribute( + 'start-page', + replacement.topicReference, + ); + changedTreePaths.add(replacement.treePath); + } XmlElement? selectedRemovalElement; if (analysis.mode == WritersideTopicRemovalMode.removeFromInstance) { @@ -779,6 +875,7 @@ class WritersideTopicRemovalService { final sourceUsageRemains = _plannedUsageRemains( analysis, updateUsagesAutomatically: request.updateUsagesAutomatically, + homePageReplacement: replacement, ); if (analysis.mode == WritersideTopicRemovalMode.safeDeleteFile && sourceUsageRemains) { @@ -1957,9 +2054,11 @@ class WritersideTopicRemovalService { bool _plannedUsageRemains( WritersideTopicRemovalAnalysis analysis, { required bool updateUsagesAutomatically, + WritersideHomePageReplacement? homePageReplacement, }) { for (final usage in analysis.usages) { if (!usage.relevant) return true; + if (analysis.resolvesHomePageUsage(usage, homePageReplacement)) continue; if (usage.kind == WritersideTopicUsageKind.tocElement && usage.canUpdateAutomatically) { continue; @@ -2074,6 +2173,82 @@ class WritersideTopicRemovalService { .toList(growable: false); } + List _homePageReplacements( + _RemovalSnapshot snapshot, + _TreeSnapshot tree, + ) { + final instance = tree.owner.instances + .where((candidate) => p.equals(candidate.sourceTreePath, tree.path)) + .firstOrNull; + if (instance == null || + instance.isLibrary || + tree.document.rootElement.getAttribute('is-library') == 'true' || + !tree.owner.config.instanceSources.any( + (source) => p.equals(p.join(tree.owner.rootPath, source), tree.path), + )) { + return const []; + } + final presenter = WritersideTocPresenter( + module: tree.owner, + instance: instance, + modulesByOrigin: snapshot.project.modulesByOrigin, + ); + final result = []; + final seen = {}; + for (final node in instance.navigationTocRoots.expand( + (root) => root.flatten(), + )) { + if (!node.canEditStructure || + !p.equals(node.sourceTreePath!, tree.path) || + node.href != null || + node.includeResolutionError != null) { + continue; + } + final element = _elementAtPath( + tree.document.rootElement, + node.sourceTocPath!, + ); + // start-page resolves in the host module without a TOC origin. Keep the + // authored topic reference and require the same resolution as Set Home. + final reference = element.getAttribute('topic'); + final topic = reference == null || reference.isEmpty + ? null + : tree.owner.topicByReference(reference); + final resolvedTopic = presenter.present(node).topic; + if (topic == null || + resolvedTopic == null || + element.getAttribute('href') != null || + !p.equals(topic.filePath, resolvedTopic.filePath) || + !snapshot.sources.containsKey(normalizePath(topic.filePath)) || + p.equals(topic.filePath, snapshot.topic.filePath) || + !seen.add(normalizePath(topic.filePath))) { + continue; + } + result.add( + WritersideHomePageReplacement( + topicPath: topic.filePath, + treePath: tree.path, + topicReference: reference!, + label: topic.title?.trim().isNotEmpty == true + ? topic.title!.trim() + : topic.fileName, + ), + ); + } + return [ + for (final candidate in result) + WritersideHomePageReplacement( + topicPath: candidate.topicPath, + treePath: candidate.treePath, + topicReference: candidate.topicReference, + label: + result.where((other) => other.label == candidate.label).length > 1 + ? '${candidate.label} (${candidate.topicReference})' + : candidate.label, + ), + ]; + } + List _resolvedRedirectTargets( _RemovalSnapshot snapshot, _TreeSnapshot tree, diff --git a/linux/io.busystack.busymark.metainfo.xml b/linux/io.busystack.busymark.metainfo.xml index 045ac604..c3062fa2 100644 --- a/linux/io.busystack.busymark.metainfo.xml +++ b/linux/io.busystack.busymark.metainfo.xml @@ -52,6 +52,7 @@ Markdown dosyaları ve Writerside uyumlu dokümantasyon projeleri için düzenleyici

BusyMark is a Linux editor for Markdown files and Writerside-compatible documentation projects.

+

Connect Nextcloud Notes remote workspaces with offline-safe synchronization, categories, favorites, and attachments.

BusyMark ist ein Linux-Editor für Markdown-Dateien und Writerside-kompatible Dokumentationsprojekte.

BusyMark is een Linux-editor voor Markdown-bestanden en Writerside-compatibele documentatieprojecten.

BusyMark on Linuxi redaktor Markdowni failide ja Writerside’iga ühilduvate dokumentatsiooniprojektide jaoks.

@@ -87,6 +88,7 @@ https://github.com/busystack/busymark/issues + diff --git a/packages/busymark_spellcheck_native/README.md b/packages/busymark_spellcheck_native/README.md index 28e98da4..baad1547 100644 --- a/packages/busymark_spellcheck_native/README.md +++ b/packages/busymark_spellcheck_native/README.md @@ -11,16 +11,17 @@ use a host-installed Hunspell library or dictionary. ## Project structure -This template uses the following structure: +The package uses the following structure: -* `src`: Contains the native source code, and a CmakeFile.txt file for building - that source code into a dynamic library. +* `src`: Contains BusyMark's C++ wrapper and C API header. * `lib`: Contains the Dart code that defines the API of the plugin, and which calls into the native code using `dart:ffi`. * `hook`: Contains `build.dart`, which performs the native-assets build. +* `third_party/hunspell`: Contains the pinned engine source and licenses. + ## Building and bundling native code `build.dart` does the building of native components. @@ -39,9 +40,3 @@ Regenerate the bindings by running `dart run ffigen --config ffigen.yaml`. Very short-running native functions can be directly invoked from any isolate. BusyMark owns handles on its long-lived spelling isolate. Callers copy returned suggestions into Dart strings before the wrapper releases their native storage. - -## Flutter help - -For help getting started with Flutter, view our -[online documentation](https://docs.flutter.dev), which offers tutorials, -samples, guidance on mobile development, and a full API reference. diff --git a/pubspec.yaml b/pubspec.yaml index 29270b42..8d465423 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.0 +version: 0.6.1 environment: sdk: ">=3.12.1 <4.0.0" diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index 6f7fd129..ff9ff326 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -1,6 +1,6 @@ name: busymark title: BusyMark -version: "0.6.0" +version: "0.6.1" 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. @@ -9,6 +9,8 @@ description: | Open individual Markdown files, documentation folders, and Writerside-style project structures. Edit local files, preview Markdown, navigate project files, inspect document outlines, and save changes in a native-feeling Linux desktop workspace. + Connect Nextcloud Notes remote workspaces with offline-safe synchronization, categories, favorites, and attachments. + license: Apache-2.0 base: core24 grade: stable diff --git a/test-results/history-runtime/clipboard-cross-process.json b/test-results/history-runtime/clipboard-cross-process.json deleted file mode 100644 index d511461d..00000000 --- a/test-results/history-runtime/clipboard-cross-process.json +++ /dev/null @@ -1 +0,0 @@ -{"passed":true,"text":true,"html":true,"rich":false} \ No newline at end of file diff --git a/test-results/history-runtime/clipboard-plain-replacement.json b/test-results/history-runtime/clipboard-plain-replacement.json deleted file mode 100644 index c53ac026..00000000 --- a/test-results/history-runtime/clipboard-plain-replacement.json +++ /dev/null @@ -1 +0,0 @@ -{"passed":true,"text":true,"html":false,"rich":false} \ No newline at end of file diff --git a/test-results/history-runtime/clipboard-roundtrip.json b/test-results/history-runtime/clipboard-roundtrip.json deleted file mode 100644 index 4bb7aaf9..00000000 --- a/test-results/history-runtime/clipboard-roundtrip.json +++ /dev/null @@ -1 +0,0 @@ -{"passed":true,"text":true,"html":true,"rich":true} \ No newline at end of file diff --git a/test-results/history-runtime/markdown-clipboard-history-1280x800.png b/test-results/history-runtime/markdown-clipboard-history-1280x800.png deleted file mode 100644 index bacfbdba..00000000 Binary files a/test-results/history-runtime/markdown-clipboard-history-1280x800.png and /dev/null differ diff --git a/test-results/history-runtime/markdown-comparison-1920x1080.png b/test-results/history-runtime/markdown-comparison-1920x1080.png deleted file mode 100644 index 3c27fc2e..00000000 Binary files a/test-results/history-runtime/markdown-comparison-1920x1080.png and /dev/null differ diff --git a/test-results/history-runtime/markdown-deleted-comparison-1920x1080.png b/test-results/history-runtime/markdown-deleted-comparison-1920x1080.png deleted file mode 100644 index 4c3762eb..00000000 Binary files a/test-results/history-runtime/markdown-deleted-comparison-1920x1080.png and /dev/null differ diff --git a/test-results/history-runtime/markdown-deleted-recovered-1920x1080.png b/test-results/history-runtime/markdown-deleted-recovered-1920x1080.png deleted file mode 100644 index a14f7b04..00000000 Binary files a/test-results/history-runtime/markdown-deleted-recovered-1920x1080.png and /dev/null differ diff --git a/test-results/history-runtime/markdown-fragment-restored-1920x1080.png b/test-results/history-runtime/markdown-fragment-restored-1920x1080.png deleted file mode 100644 index 06e5926a..00000000 Binary files a/test-results/history-runtime/markdown-fragment-restored-1920x1080.png and /dev/null differ diff --git a/test-results/history-runtime/markdown-local-history-1280x800.png b/test-results/history-runtime/markdown-local-history-1280x800.png deleted file mode 100644 index 3731346f..00000000 Binary files a/test-results/history-runtime/markdown-local-history-1280x800.png and /dev/null differ diff --git a/test-results/history-runtime/markdown-report.json b/test-results/history-runtime/markdown-report.json deleted file mode 100644 index feba0bc2..00000000 --- a/test-results/history-runtime/markdown-report.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "passed": true, - "checks": { - "native clipboard publication": true, - "explicit save captured": true, - "exact comparison region available": true, - "fragment restore": true, - "recovery file open": true, - "document delete": true, - "deleted document recovery": true, - "deletedFileRecovered": true, - "clipboardEntries": true, - "revisionReasons": true - }, - "screenshots": [ - "markdown-clipboard-history-1280x800.png", - "markdown-local-history-1280x800.png", - "markdown-comparison-1920x1080.png", - "markdown-fragment-restored-1920x1080.png", - "markdown-deleted-comparison-1920x1080.png", - "markdown-deleted-recovered-1920x1080.png" - ] -} diff --git a/test-results/history-runtime/writerside-clipboard-history-1280x800.png b/test-results/history-runtime/writerside-clipboard-history-1280x800.png deleted file mode 100644 index da669115..00000000 Binary files a/test-results/history-runtime/writerside-clipboard-history-1280x800.png and /dev/null differ diff --git a/test-results/history-runtime/writerside-comparison-1920x1080.png b/test-results/history-runtime/writerside-comparison-1920x1080.png deleted file mode 100644 index ced6b5fd..00000000 Binary files a/test-results/history-runtime/writerside-comparison-1920x1080.png and /dev/null differ diff --git a/test-results/history-runtime/writerside-fragment-restored-1920x1080.png b/test-results/history-runtime/writerside-fragment-restored-1920x1080.png deleted file mode 100644 index c81f68fb..00000000 Binary files a/test-results/history-runtime/writerside-fragment-restored-1920x1080.png and /dev/null differ diff --git a/test-results/history-runtime/writerside-local-history-1280x800.png b/test-results/history-runtime/writerside-local-history-1280x800.png deleted file mode 100644 index d29fd14a..00000000 Binary files a/test-results/history-runtime/writerside-local-history-1280x800.png and /dev/null differ diff --git a/test-results/history-runtime/writerside-report.json b/test-results/history-runtime/writerside-report.json deleted file mode 100644 index f055d27c..00000000 --- a/test-results/history-runtime/writerside-report.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "passed": true, - "checks": { - "native clipboard publication": true, - "explicit save captured": true, - "exact comparison region available": true, - "fragment restore": true, - "clipboardEntries": true, - "revisionReasons": true - }, - "screenshots": [ - "writerside-clipboard-history-1280x800.png", - "writerside-local-history-1280x800.png", - "writerside-comparison-1920x1080.png", - "writerside-fragment-restored-1920x1080.png" - ] -} diff --git a/test/src/app_smoke_test.dart b/test/src/app_smoke_test.dart index 5e31eef9..fd2d2b99 100644 --- a/test/src/app_smoke_test.dart +++ b/test/src/app_smoke_test.dart @@ -5530,6 +5530,981 @@ code }, ); + Future waitForTabAction( + WidgetTester tester, + bool Function() done, + ) async { + for (var i = 0; i < 1500 && !done(); i++) { + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 10)), + ); + await tester.pump(const Duration(milliseconds: 10)); + } + expect(done(), isTrue, reason: 'Tab action did not finish'); + await tester.pumpAndSettle(); + } + + Future tabOperation( + WidgetTester tester, + Future Function() operation, + ) async { + late T result; + Object? error; + StackTrace? stack; + var done = false; + unawaited( + operation().then( + (value) { + result = value; + done = true; + }, + onError: (Object caught, StackTrace trace) { + error = caught; + stack = trace; + done = true; + }, + ), + ); + await waitForTabAction(tester, () => done); + if (error != null) Error.throwWithStackTrace(error!, stack!); + return result; + } + + Future< + ({ + ProviderContainer container, + WorkspaceController controller, + _TabbedWorkspaceService service, + List paths, + }) + > + tabWorkspace( + WidgetTester tester, { + int count = 3, + bool git = false, + WorkspaceKind kind = WorkspaceKind.markdownFolder, + }) async { + final temp = Directory.systemTemp.createTempSync('busymark_tab_actions_'); + addTearDown(() => temp.deleteSync(recursive: true)); + final paths = [ + for (var i = 0; i < count; i++) + '${temp.path}/${['a space 文.md', 'b.md', 'c.md'][i]}', + ]; + for (final path in paths) { + File(path).writeAsStringSync('# Original\n'); + } + final service = _TabbedWorkspaceService( + rootPath: temp.path, + paths: paths, + kind: kind, + ); + final container = ProviderContainer( + overrides: [ + ..._smokeAccentOverrides, + linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), + localSettingsStoreProvider.overrideWithValue( + _MemorySettingsStore() + ..value = AppSettings.defaults() + .copyWith( + autoSave: false, + validateOnEdit: false, + documentViewMode: DocumentViewModePreference.source, + ) + .toJson(), + ), + workspaceServiceProvider.overrideWithValue(service), + startupPathProvider.overrideWithValue(temp.path), + if (git) + gitControllerProvider.overrideWith( + () => _PresetGitController(_gitDiffState(temp.path)), + ), + ], + ); + addTearDown(container.dispose); + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const BusyMarkApp(), + ), + ); + for ( + var i = 0; + i < 40 && container.read(workspaceControllerProvider).workspace == null; + i++ + ) { + await tester.pump(const Duration(milliseconds: 100)); + } + final controller = container.read(workspaceControllerProvider.notifier); + await tabOperation(tester, () async { + for (final path in paths.skip(1)) { + await controller.openActiveFile(path); + } + }); + await tester.pumpAndSettle(); + return ( + container: container, + controller: controller, + service: service, + paths: paths, + ); + } + + Finder documentTab(DocumentBuffer buffer) => + find.byKey(ValueKey('file:${buffer.id}')); + Finder tabLabel(DocumentBuffer buffer) => find + .descendant(of: documentTab(buffer), matching: find.byType(Text)) + .first; + Future openTabMenu(WidgetTester tester, DocumentBuffer buffer) async { + await tester.ensureVisible(documentTab(buffer)); + await tester.pumpAndSettle(); + await tester.tap(tabLabel(buffer), buttons: kSecondaryButton); + await tester.pumpAndSettle(); + } + + Future openTabComparison( + WidgetTester tester, + ProviderContainer container, + ) async { + final history = container.read(localHistoryControllerProvider.notifier); + final buffer = container.read(workspaceControllerProvider).activeBuffer!; + // Keep these memory-store operations in the test's zone so pending history + // work and its completion callbacks can drain through pump(). + var done = false; + unawaited(() async { + await history.flushAll( + container.read(workspaceControllerProvider).documentBuffers, + ); + await history.selectDocumentForBuffer(buffer); + await history.selectRevision( + container + .read(localHistoryControllerProvider) + .selectedRevisions + .first + .id, + ); + done = true; + }()); + await waitForTabAction(tester, () => done); + expect( + container.read(localHistoryControllerProvider).selectedRevision, + isNotNull, + ); + } + + for (final count in [1, 3]) { + testWidgets( + 'document tab menu ordering and dismissal with $count documents', + (tester) async { + final h = await tabWorkspace(tester, count: count); + final before = h.container.read(workspaceControllerProvider); + await openTabMenu(tester, before.documentBuffers.first); + final entries = tester + .widgetList>( + find.byWidgetPredicate((w) => w is PopupMenuEntry), + ) + .toList(); + expect( + entries.map( + (e) => e is BusyMarkPopupMenuItem ? e.label : 'separator', + ), + [ + l10n.close, + l10n.closeOtherTabs, + l10n.shortcutCloseAllTabs, + 'separator', + l10n.copyPath, + 'separator', + l10n.localHistoryEllipsis, + ], + ); + expect((entries[1] as PopupMenuItem).enabled, count > 1); + expect((entries[4] as PopupMenuItem).enabled, isTrue); + expect( + entries.whereType().map((e) => e.height), + everyElement(BusyMarkSpacing.sm), + ); + expect( + h.container.read(workspaceControllerProvider).activeBufferId, + before.activeBufferId, + ); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + final after = h.container.read(workspaceControllerProvider); + expect(after.activeBufferId, before.activeBufferId); + expect( + after.documentBuffers.map((b) => b.id), + before.documentBuffers.map((b) => b.id), + ); + expect(after.activeText, before.activeText); + }, + ); + } + + for (final trigger in ['menu', 'middle', 'cross']) { + for (final active in [false, true]) { + testWidgets( + '$trigger closes exactly the ${active ? 'active' : 'inactive'} document tab', + (tester) async { + final h = await tabWorkspace(tester); + final before = h.container.read(workspaceControllerProvider); + final target = active + ? before.activeBuffer! + : before.documentBuffers.first; + if (trigger == 'menu') { + await openTabMenu(tester, target); + await tester.tap(find.text(l10n.close)); + } else if (trigger == 'middle') { + final gesture = await tester.startGesture( + tester.getCenter(tabLabel(target)), + kind: PointerDeviceKind.mouse, + buttons: kTertiaryButton, + ); + await tester.pump(); + expect( + h.container.read(workspaceControllerProvider).documentBuffers, + hasLength(3), + ); + expect( + h.container.read(workspaceControllerProvider).activeBufferId, + before.activeBufferId, + ); + await gesture.up(); + } else { + await tester.tap( + find.descendant( + of: documentTab(target), + matching: find.byTooltip('Close'), + ), + ); + } + await waitForTabAction( + tester, + () => + h.container + .read(workspaceControllerProvider) + .documentBuffers + .length == + 2, + ); + final after = h.container.read(workspaceControllerProvider); + expect( + after.documentBuffers.map((b) => b.id), + before.documentBuffers + .where((b) => b.id != target.id) + .map((b) => b.id), + ); + if (!active) expect(after.activeBufferId, before.activeBufferId); + expect(h.service.saveCount, 0); + }, + ); + } + } + + for (final trigger in [ + 'middle', + 'menu', + 'cross', + 'keyboard', + 'allKeyboard', + ]) { + for (final action in ['Cancel', 'Save', 'Discard']) { + testWidgets('$trigger closes dirty document with $action once', ( + tester, + ) async { + final h = await tabWorkspace(tester); + await tabOperation( + tester, + () => h.controller.activateDocumentBuffer( + h.container + .read(workspaceControllerProvider) + .documentBuffers + .first + .id, + ), + ); + h.controller.updateActiveText('# Unsaved target\n'); + h.controller.updateActiveEditorState( + h.container + .read(workspaceControllerProvider) + .activeBuffer! + .editorState + .copyWith( + mode: DocumentViewModePreference.source, + selection: const TextSelection(baseOffset: 2, extentOffset: 9), + ), + ); + await tester.pumpAndSettle(); + final target = h.container + .read(workspaceControllerProvider) + .activeBuffer!; + await tabOperation( + tester, + () => h.controller.activateDocumentBuffer( + h.container + .read(workspaceControllerProvider) + .documentBuffers + .last + .id, + ), + ); + await tester.pumpAndSettle(); + if (trigger == 'keyboard') { + await tabOperation( + tester, + () => h.controller.activateDocumentBuffer(target.id), + ); + } + final activeId = h.container + .read(workspaceControllerProvider) + .activeBufferId; + if (trigger == 'middle') { + await tester.tap(tabLabel(target), buttons: kTertiaryButton); + } else if (trigger == 'menu') { + await openTabMenu(tester, target); + await tester.tap(find.text(l10n.close)); + } else if (trigger == 'cross') { + await tester.tap( + find.descendant( + of: documentTab(target), + matching: find.byTooltip('Close'), + ), + ); + } else { + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + if (trigger == 'allKeyboard') { + await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); + } + await tester.sendKeyEvent(LogicalKeyboardKey.keyW); + if (trigger == 'allKeyboard') { + await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); + } + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + } + await tester.pumpAndSettle(); + expect(find.text(l10n.unsavedChanges), findsOneWidget); + expect( + h.container.read(workspaceControllerProvider).activeBufferId, + activeId, + ); + await tester.tap(find.text(action)); + await tester.pumpAndSettle(); + if (action != 'Cancel') { + await waitForTabAction( + tester, + () => + h.container + .read(workspaceControllerProvider) + .documentBuffers + .length == + (trigger == 'allKeyboard' ? 0 : 2), + ); + } + final remaining = h.container + .read(workspaceControllerProvider) + .documentBuffers; + if (action == 'Cancel') { + final kept = remaining.singleWhere((b) => b.id == target.id); + expect(kept.text, target.text); + expect(kept.isDirty, isTrue); + expect(kept.editorState.selection, target.editorState.selection); + expect( + kept.editorState.undoState, + same(target.editorState.undoState), + ); + expect( + h.container.read(workspaceControllerProvider).activeBufferId, + activeId, + ); + } else { + expect(remaining.any((b) => b.id == target.id), isFalse); + expect(remaining, hasLength(trigger == 'allKeyboard' ? 0 : 2)); + } + expect(h.service.saveCount, action == 'Save' ? 1 : 0); + }); + } + } + + testWidgets( + 'wheel rotation and primary and secondary tab interactions remain independent', + (tester) async { + final h = await tabWorkspace(tester); + final target = h.container + .read(workspaceControllerProvider) + .documentBuffers + .first; + await tester.sendEventToBinding( + PointerScrollEvent( + position: tester.getCenter(tabLabel(target)), + scrollDelta: const Offset(0, 24), + ), + ); + await tester.pumpAndSettle(); + expect( + h.container.read(workspaceControllerProvider).documentBuffers, + hasLength(3), + ); + await openTabMenu(tester, target); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + expect( + h.container.read(workspaceControllerProvider).activeBufferId, + isNot(target.id), + ); + await tester.tap(tabLabel(target)); + await tester.pumpAndSettle(); + expect( + h.container.read(workspaceControllerProvider).activeBufferId, + target.id, + ); + }, + ); + + for (final bulk in ['other', 'all']) { + testWidgets( + 'close $bulk document tabs also closes Git and Local History comparisons', + (tester) async { + final h = await tabWorkspace(tester, git: true); + final retainedId = h.container + .read(workspaceControllerProvider) + .documentBuffers + .first + .id; + await tabOperation( + tester, + () => h.controller.activateDocumentBuffer(retainedId), + ); + h.controller.updateActiveText('# Retained unsaved draft\n'); + h.controller.updateActiveEditorState( + h.container + .read(workspaceControllerProvider) + .activeBuffer! + .editorState + .copyWith( + mode: DocumentViewModePreference.source, + selection: const TextSelection(baseOffset: 3, extentOffset: 11), + scrollOffset: 12, + foldedRegionKeys: const {'keep-fold'}, + ), + ); + await tester.pumpAndSettle(); + await tester.pumpAndSettle(); + await tabOperation( + tester, + () => h.controller.activateDocumentBuffer( + h.container + .read(workspaceControllerProvider) + .documentBuffers + .last + .id, + ), + ); + await tester.pumpAndSettle(); + final retained = h.container + .read(workspaceControllerProvider) + .documentBuffers + .first; + await openTabComparison(tester, h.container); + final historyBefore = h.container + .read(localHistoryControllerProvider) + .snapshot + .revisions + .length; + await openTabMenu(tester, retained); + await tester.tap( + find.text( + bulk == 'other' ? l10n.closeOtherTabs : l10n.shortcutCloseAllTabs, + ), + ); + await tester.pumpAndSettle(); + if (bulk == 'all') { + expect(find.text(l10n.unsavedChanges), findsOneWidget); + await tester.tap(find.text(l10n.discard)); + await tester.pumpAndSettle(); + } else { + expect(find.text(l10n.unsavedChanges), findsNothing); + } + await waitForTabAction( + tester, + () => + h.container + .read(workspaceControllerProvider) + .documentBuffers + .length == + (bulk == 'other' ? 1 : 0), + ); + final after = h.container.read(workspaceControllerProvider); + expect( + h.container.read(gitControllerProvider).openDiffFilePaths, + isEmpty, + ); + expect( + h.container.read(gitControllerProvider).selectedDiffForDisplay, + isNull, + ); + expect( + h.container.read(localHistoryControllerProvider).selectedRevisionId, + isNull, + ); + expect( + h.container + .read(localHistoryControllerProvider) + .snapshot + .revisions + .length, + greaterThanOrEqualTo(historyBefore), + ); + expect(h.service.saveCount, 0); + if (bulk == 'other') { + expect(after.documentBuffers.single.id, retained.id); + expect(after.activeBufferId, retained.id); + expect(after.activeBuffer!.text, retained.text); + expect(after.activeBuffer!.isDirty, isTrue); + expect(retained.editorState.undoState.undo, isNotEmpty); + expect( + after.activeBuffer!.editorState.selection, + retained.editorState.selection, + ); + expect( + after.activeBuffer!.editorState.foldedRegionKeys, + retained.editorState.foldedRegionKeys, + ); + expect( + after.activeBuffer!.editorState.undoState, + same(retained.editorState.undoState), + ); + } else { + expect(after.documentBuffers, isEmpty); + expect(after.workspace!.kind, WorkspaceKind.markdownFolder); + expect(after.workspace!.openFilePaths, isEmpty); + } + }, + ); + } + + testWidgets( + 'comparison-only neighbors enable Close other tabs and middle click closes comparison views', + (tester) async { + final h = await tabWorkspace(tester, count: 1, git: true); + await openTabComparison(tester, h.container); + final target = h.container + .read(workspaceControllerProvider) + .activeBuffer!; + await openTabMenu(tester, target); + final other = tester.widget>( + find.byWidgetPredicate( + (w) => w is BusyMarkPopupMenuItem && w.label == l10n.closeOtherTabs, + ), + ); + expect(other.enabled, isTrue); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + final revision = h.container + .read(localHistoryControllerProvider) + .selectedRevisionId; + await tester.scrollUntilVisible( + find.byKey(ValueKey('localHistory:$revision')), + 180, + scrollable: find + .descendant( + of: find.byKey(const ValueKey('editor-tab-strip')), + matching: find.byType(Scrollable), + ) + .first, + ); + await tester.pumpAndSettle(); + await tester.tap( + find.descendant( + of: find.byKey(ValueKey('localHistory:$revision')), + matching: find.byType(Text), + ), + buttons: kTertiaryButton, + ); + await tester.pumpAndSettle(); + expect( + h.container.read(localHistoryControllerProvider).selectedRevisionId, + isNull, + ); + await tester.tap( + find.descendant( + of: find.byKey(const ValueKey('gitDiff:README.md')), + matching: find.byType(Text), + ), + buttons: kTertiaryButton, + ); + await tester.pumpAndSettle(); + expect(h.container.read(gitControllerProvider).openDiffFilePaths, [ + 'guide.md', + ]); + await openTabMenu(tester, target); + await tester.tap(find.text(l10n.closeOtherTabs)); + await tester.pumpAndSettle(); + expect( + h.container.read(gitControllerProvider).openDiffFilePaths, + isEmpty, + ); + expect( + h.container.read(workspaceControllerProvider).activeBufferId, + target.id, + ); + }, + ); + + testWidgets('bulk close survives the strip disappearing', (tester) async { + final h = await tabWorkspace(tester, kind: WorkspaceKind.singleMarkdown); + final target = h.container + .read(workspaceControllerProvider) + .documentBuffers + .first; + await openTabMenu(tester, target); + await tester.tap(find.text(l10n.closeOtherTabs)); + await tester.pumpAndSettle(); + await waitForTabAction( + tester, + () => + h.container + .read(workspaceControllerProvider) + .documentBuffers + .length == + 1, + ); + expect( + h.container.read(workspaceControllerProvider).documentBuffers.single.id, + target.id, + ); + expect(find.byKey(const ValueKey('editor-tab-strip')), findsNothing); + expect(tester.takeException(), isNull); + }); + + testWidgets( + 'Copy path uses exact live inactive path even after Save As or deletion', + (tester) async { + String? clipboard; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + (call) async { + if (call.method == 'Clipboard.setData') { + clipboard = (call.arguments as Map)['text'] as String; + } + return null; + }, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + null, + ), + ); + final h = await tabWorkspace(tester); + var target = h.container + .read(workspaceControllerProvider) + .documentBuffers + .first; + final active = h.container + .read(workspaceControllerProvider) + .activeBufferId; + await openTabMenu(tester, target); + await tester.tap(find.text(l10n.copyPath)); + await tester.pumpAndSettle(); + expect(clipboard, h.paths.first); + expect( + h.container.read(workspaceControllerProvider).activeBufferId, + active, + ); + File(h.paths.first).deleteSync(); + await openTabMenu(tester, target); + await tester.tap(find.text(l10n.copyPath)); + await tester.pumpAndSettle(); + expect(clipboard, h.paths.first); + await tabOperation(tester, () => h.controller.createMarkdownFile()); + target = h.container.read(workspaceControllerProvider).activeBuffer!; + await tester.pumpAndSettle(); + await openTabMenu(tester, target); + final copy = tester.widget>( + find.byWidgetPredicate( + (w) => w is BusyMarkPopupMenuItem && w.label == l10n.copyPath, + ), + ); + expect(copy.enabled, isFalse); + await tester.tap(find.text(l10n.copyPath)); + await tester.pump(); + expect(clipboard, h.paths.first); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + final newPath = '${h.service.rootPath}/saved 文 space.md'; + await openTabMenu( + tester, + h.container.read(workspaceControllerProvider).documentBuffers[1], + ); + // A live path change while a native menu is pending is covered below. + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + await tabOperation(tester, () => h.controller.saveActiveAs(newPath)); + await tester.pumpAndSettle(); + target = h.container.read(workspaceControllerProvider).activeBuffer!; + await tabOperation( + tester, + () => h.controller.activateDocumentBuffer( + h.container.read(workspaceControllerProvider).documentBuffers[1].id, + ), + ); + await tester.pumpAndSettle(); + await openTabMenu(tester, target); + await tester.tap(find.text(l10n.copyPath)); + await tester.pumpAndSettle(); + expect(clipboard, newPath); + }, + ); + + for (final selected in [0, 1, 2, 4, 6]) { + testWidgets( + 'native document menu serializes entries and dispatches index $selected', + (tester) async { + final h = await tabWorkspace(tester); + final before = h.container.read(workspaceControllerProvider); + final target = before.documentBuffers.first; + Map? shown; + String? clipboard; + const channel = MethodChannel('busymark/native_menus'); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + channel, + (call) async { + if (call.method == 'show') { + shown = call.arguments as Map; + return selected; + } + return true; + }, + ); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + (call) async { + if (call.method == 'Clipboard.setData') { + clipboard = (call.arguments as Map)['text'] as String; + } + return null; + }, + ); + addTearDown(() { + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + channel, + null, + ); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + null, + ); + }); + await openTabMenu(tester, target); + expect((shown!['entries'] as List).map((e) => (e as Map)['label']), [ + l10n.close, + l10n.closeOtherTabs, + l10n.shortcutCloseAllTabs, + '', + l10n.copyPath, + '', + l10n.localHistoryEllipsis, + ]); + expect( + (shown!['entries'] as List).map((e) => (e as Map)['separator']), + [false, false, false, true, false, true, false], + ); + expect((shown!['entries'] as List).map((e) => (e as Map)['enabled']), [ + true, + true, + true, + false, + true, + false, + true, + ]); + if (selected <= 2) { + await waitForTabAction( + tester, + () => + h.container + .read(workspaceControllerProvider) + .documentBuffers + .length == + [2, 1, 0][selected], + ); + } + if (selected == 6) { + await waitForTabAction( + tester, + () => + h.container + .read(localHistoryControllerProvider) + .selectedDocument != + null, + ); + } + final after = h.container.read(workspaceControllerProvider); + if (selected == 0) { + expect(after.documentBuffers.any((b) => b.id == target.id), isFalse); + } + if (selected == 1) expect(after.documentBuffers.single.id, target.id); + if (selected == 2) expect(after.documentBuffers, isEmpty); + if (selected == 4) { + expect(clipboard, target.filePath); + expect(after.activeBufferId, before.activeBufferId); + } + if (selected == 6) { + expect(after.activeBufferId, target.id); + expect( + h.container + .read(localHistoryControllerProvider) + .selectedDocument + ?.currentPath, + target.filePath, + ); + expect(find.byType(LocalHistoryPanel), findsOneWidget); + } + }, + ); + } + + testWidgets( + 'pending native Close follows an untitled buffer through its first save', + (tester) async { + final h = await tabWorkspace( + tester, + count: 1, + kind: WorkspaceKind.singleMarkdown, + ); + await tabOperation(tester, () => h.controller.createMarkdownWorkspace()); + h.controller.updateActiveText('# First-save target\n'); + await tester.pumpAndSettle(); + final target = h.container + .read(workspaceControllerProvider) + .activeBuffer!; + await tabOperation(tester, () => h.controller.createMarkdownFile()); + final retainedId = h.container + .read(workspaceControllerProvider) + .activeBufferId; + final result = Completer(); + const channel = MethodChannel('busymark/native_menus'); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, ( + call, + ) async { + if (call.method == 'show') return await result.future; + return true; + }); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + channel, + null, + ), + ); + await tester.tap(tabLabel(target), buttons: kSecondaryButton); + await tester.pump(); + await tabOperation(tester, () async { + await h.controller.activateDocumentBuffer(target.id); + await h.controller.saveActiveAs( + '${h.service.rootPath}/first saved 文.md', + ); + await h.controller.activateDocumentBuffer(retainedId!); + }); + expect( + h.container.read(workspaceControllerProvider).workspace!.kind, + WorkspaceKind.singleMarkdown, + ); + result.complete(0); + await waitForTabAction( + tester, + () => + h.container + .read(workspaceControllerProvider) + .documentBuffers + .length == + 1, + ); + final after = h.container.read(workspaceControllerProvider); + expect(after.documentBuffers.single.id, retainedId); + expect(after.documentBuffers.single.isDirty, isTrue); + expect(find.text(l10n.unsavedChanges), findsNothing); + }, + ); + + for (final actionIndex in [0, 4]) { + for (final change in ['remove', 'workspace', 'path']) { + testWidgets( + 'pending native menu index $actionIndex validates live target after $change', + (tester) async { + final h = await tabWorkspace(tester); + final target = h.container + .read(workspaceControllerProvider) + .documentBuffers + .first; + final result = Completer(); + String? clipboard; + const channel = MethodChannel('busymark/native_menus'); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + channel, + (call) async { + if (call.method == 'show') return await result.future; + return true; + }, + ); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + (call) async { + if (call.method == 'Clipboard.setData') { + clipboard = (call.arguments as Map)['text'] as String; + } + return null; + }, + ); + addTearDown(() { + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + channel, + null, + ); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + null, + ); + }); + await tester.tap(tabLabel(target), buttons: kSecondaryButton); + await tester.pump(); + String? newPath; + await tabOperation(tester, () async { + if (change == 'remove') { + await h.controller.closeDocumentBuffer(target.id); + } + if (change == 'workspace') { + await h.controller.createMarkdownWorkspace(); + } + if (change == 'path') { + await h.controller.activateDocumentBuffer(target.id); + newPath = '${h.service.rootPath}/renamed 文 space.md'; + await h.controller.saveActiveAs(newPath!); + } + }); + final beforeDispatch = h.container.read(workspaceControllerProvider); + result.complete(actionIndex); + await tester.pumpAndSettle(); + final after = h.container.read(workspaceControllerProvider); + if (change == 'path') { + if (actionIndex == 4) expect(clipboard, newPath); + if (actionIndex == 0) { + expect( + after.documentBuffers.any((b) => b.id == target.id), + isFalse, + ); + } + } else { + expect(clipboard, isNull); + expect( + after.documentBuffers.map((b) => b.id), + beforeDispatch.documentBuffers.map((b) => b.id), + ); + expect(after.activeBufferId, beforeDispatch.activeBufferId); + } + }, + ); + } + } + testWidgets('tab keyboard shortcuts move and close editor tabs', ( tester, ) async { @@ -14488,8 +15463,13 @@ class _StartupWorkspaceService extends WorkspaceService { } class _TabbedWorkspaceService extends WorkspaceService { - _TabbedWorkspaceService({required this.rootPath, required this.paths}); + _TabbedWorkspaceService({ + required this.rootPath, + required this.paths, + this.kind = WorkspaceKind.markdownFolder, + }); + final WorkspaceKind kind; final String rootPath; final List paths; final _sources = {}; @@ -14512,7 +15492,7 @@ class _TabbedWorkspaceService extends WorkspaceService { return Workspace( id: rootPath, rootPath: rootPath, - kind: WorkspaceKind.markdownFolder, + kind: kind, openedAt: DateTime(2026), activeFilePath: paths.first, activeFileModifiedAt: DateTime(2026), diff --git a/test/src/local_history_controller_test.dart b/test/src/local_history_controller_test.dart index fda429ba..3dc4ab3e 100644 --- a/test/src/local_history_controller_test.dart +++ b/test/src/local_history_controller_test.dart @@ -3626,6 +3626,677 @@ void main() { }, ); + for (final scenario in [ + ( + deletion: false, + recursive: false, + source: '/workspace/A.md', + destination: '/workspace/Z.md', + dependent: true, + ), + ( + deletion: false, + recursive: false, + source: '/workspace/B.md', + destination: '/workspace/Z.md', + dependent: true, + ), + ( + deletion: false, + recursive: false, + source: '/workspace/C.md', + destination: '/workspace/A.md', + dependent: true, + ), + ( + deletion: false, + recursive: false, + source: '/workspace/C.md', + destination: '/workspace/B.md', + dependent: true, + ), + ( + deletion: false, + recursive: false, + source: '/workspace/dirA/nested/C.md', + destination: '/workspace/Z.md', + dependent: true, + ), + ( + deletion: false, + recursive: false, + source: '/workspace/dirB/nested/C.md', + destination: '/workspace/Z.md', + dependent: true, + ), + ( + deletion: false, + recursive: false, + source: '/workspace/C.md', + destination: '/workspace/dirA/nested/C.md', + dependent: true, + ), + ( + deletion: false, + recursive: false, + source: '/workspace/C.md', + destination: '/workspace/dirB/nested/C.md', + dependent: true, + ), + ( + deletion: false, + recursive: false, + source: '/workspace', + destination: '/other', + dependent: true, + ), + ( + deletion: false, + recursive: false, + source: '/other', + destination: '/workspace', + dependent: true, + ), + ( + deletion: false, + recursive: false, + source: '/workspace/dirA-sibling/C.md', + destination: '/workspace/dirB-sibling/C.md', + dependent: false, + ), + ( + deletion: false, + recursive: false, + source: '/workspace/C.md', + destination: '/workspace/D.md', + dependent: false, + ), + ( + deletion: true, + recursive: true, + source: '/workspace/dirA/nested/C.md', + destination: '/workspace/Z.md', + dependent: true, + ), + ( + deletion: true, + recursive: true, + source: '/workspace/C.md', + destination: '/workspace/dirA/nested/C.md', + dependent: true, + ), + ( + deletion: true, + recursive: true, + source: '/workspace', + destination: '/other', + dependent: true, + ), + ( + deletion: true, + recursive: true, + source: '/workspace/C.md', + destination: '/workspace/D.md', + dependent: false, + ), + ( + deletion: true, + recursive: false, + source: '/workspace/A.md', + destination: '/workspace/Z.md', + dependent: true, + ), + ( + deletion: true, + recursive: false, + source: '/workspace/C.md', + destination: '/workspace/D.md', + dependent: false, + ), + ]) { + test('path dependency namespaces $scenario', () async { + final store = _ControllableRemapStore(); + final container = _historyContainer(store, <_FakeTimer>[]); + addTearDown(container.dispose); + final controller = container.read( + localHistoryControllerProvider.notifier, + ); + await Future.delayed(Duration.zero); + final directory = + scenario.recursive || + scenario.source.contains('dir') || + scenario.destination.contains('dir'); + final pendingSource = directory ? '/workspace/dirA' : '/workspace/A.md'; + final pendingDestination = directory + ? '/workspace/dirB' + : '/workspace/B.md'; + final original = _fileBuffer( + 'pending-owner', + directory ? '$pendingSource/original.md' : pendingSource, + 'original history', + ); + await controller.observeOpened(original); + expect(await controller.flushBuffer(original), isTrue); + final targets = await store.resolvePathTargets( + pendingSource, + recursive: true, + ); + store.failingReconciliationSources.add(pendingSource); + final pending = scenario.deletion + ? LocalHistoryPathReconciliation.deletion( + operationId: 'pending-deletion', + sourcePath: pendingSource, + recursive: scenario.recursive, + targets: targets, + ) + : LocalHistoryPathReconciliation.remap( + operationId: 'pending-remap', + sourcePath: pendingSource, + destinationPath: pendingDestination, + targets: targets, + ); + expect( + await controller.restorePendingPathReconciliations([pending]), + isFalse, + ); + final retainedPending = controller.pendingPathReconciliations.single; + final attempts = store.reconciliationAttempts.length; + var filesystemCalled = false; + final operation = controller.runStagedPathRemap( + sourcePath: scenario.source, + destinationPath: scenario.destination, + filesystemOperation: () async { + filesystemCalled = true; + return true; + }, + didCommit: (value) => value, + ); + if (scenario.dependent) { + await expectLater( + operation, + throwsA(isA()), + ); + } else { + expect(await operation, isTrue); + expect( + store.reconciliationAttempts.skip(attempts), + isNot(contains(pendingSource)), + ); + } + expect(filesystemCalled, !scenario.dependent); + expect( + controller.pendingPathReconciliations.single.toJson(), + retainedPending.toJson(), + ); + expect( + (await store.load()).documents.single.currentPath, + original.filePath, + ); + }); + } + + for (final binding in [ + 'prepared target', + 'bound identity', + 'destination identity', + ]) { + test('path dependency uses $binding with different paths', () async { + final store = _ControllableRemapStore(); + final container = _historyContainer(store, <_FakeTimer>[]); + addTearDown(container.dispose); + final controller = container.read( + localHistoryControllerProvider.notifier, + ); + await Future.delayed(Duration.zero); + final original = _fileBuffer( + 'identity-owner', + '/elsewhere/X.md', + 'same identity', + ); + await controller.observeOpened(original); + expect(await controller.flushBuffer(original), isTrue); + final target = (await store.resolvePathTargets( + original.filePath!, + recursive: false, + )).single; + final pending = LocalHistoryPathReconciliation.remap( + operationId: 'pending-identity-remap', + sourcePath: '/workspace/A.md', + destinationPath: '/workspace/B.md', + targets: [target.atPath('/workspace/A.md')], + ); + store.failingReconciliationSources.add(pending.sourcePath); + expect( + await controller.restorePendingPathReconciliations([pending]), + isFalse, + ); + var filesystemCalled = false; + await expectLater( + controller.runStagedPathRemap( + sourcePath: '/unrelated/C.md', + destinationPath: binding == 'destination identity' + ? original.filePath! + : '/unrelated/D.md', + preparedTargets: binding == 'prepared target' ? [target] : null, + boundBufferId: binding == 'bound identity' ? original.id : null, + filesystemOperation: () async { + filesystemCalled = true; + return true; + }, + didCommit: (value) => value, + ), + throwsA(isA()), + ); + expect(filesystemCalled, isFalse); + expect( + controller.pendingPathReconciliations.single.toJson(), + pending.toJson(), + ); + }); + } + + test( + 'path dependency checks promotion destination identity at a different path', + () async { + final root = await Directory.systemTemp.createTemp( + 'history-promotion-dependency-', + ); + addTearDown(() => root.delete(recursive: true)); + final destination = p.join(root.path, 'C.md'); + const newText = 'new first-save lineage'; + await File(destination).writeAsString(newText); + final store = _ControllableRemapStore(); + final old = await store.capture( + LocalHistoryCaptureRequest( + path: destination, + displayName: 'C.md', + source: 'older destination lineage\n', + format: TextFormatMetadata.utf8Lf, + capturedAt: DateTime.utc(2026, 1, 1), + reason: LocalHistoryCaptureReason.baseline, + ), + const LocalHistoryPolicy(), + ); + final container = _historyContainer(store, <_FakeTimer>[]); + addTearDown(container.dispose); + final controller = container.read( + localHistoryControllerProvider.notifier, + ); + await Future.delayed(Duration.zero); + final untitled = DocumentBuffer.untitled( + id: 'promotion-owner', + name: 'Untitled', + text: newText, + ); + await controller.observeOpened(untitled); + final sourceId = controller.documentIdForBuffer(untitled.id)!; + final target = (await store.resolvePathTargets( + destination, + recursive: false, + )).single; + final pending = LocalHistoryPathReconciliation.remap( + operationId: 'old-destination-identity', + sourcePath: p.join(root.path, 'A.md'), + destinationPath: p.join(root.path, 'B.md'), + targets: [target.atPath(p.join(root.path, 'A.md'))], + ); + store.failingReconciliationSources.add(pending.sourcePath); + expect( + await controller.restorePendingPathReconciliations([pending]), + isFalse, + ); + store.reconciliationAttempts.clear(); + expect( + await controller.captureSavedAs( + LocalHistoryBufferSnapshot.fromBuffer(untitled), + destination, + destinationExisted: false, + ), + isFalse, + ); + expect(store.reconciliationAttempts, [pending.sourcePath]); + // The retained promotion's retry must enforce the same dependency. + expect(await controller.flushBuffer(untitled), isFalse); + final snapshot = await store.load(); + final destinationOwner = snapshot.documents.singleWhere( + (d) => d.id == old.document.id, + ); + expect(destinationOwner.deleted, isFalse); + expect(destinationOwner.currentPath, destination); + expect( + snapshot.documents.singleWhere((d) => d.id == sourceId).untitled, + isTrue, + ); + expect( + controller.pendingPathReconciliations.single.toJson(), + pending.toJson(), + ); + }, + ); + + test( + 'path dependency settles only conflicting entries behind an unresolved head', + () async { + final store = _ControllableRemapStore(); + final container = _historyContainer(store, <_FakeTimer>[]); + addTearDown(container.dispose); + final controller = container.read( + localHistoryControllerProvider.notifier, + ); + await Future.delayed(Duration.zero); + final a = _fileBuffer('a-owner', '/workspace/A.md', 'A history'); + final x = _fileBuffer('x-owner', '/workspace/X.md', 'X history'); + await controller.observeOpened(a); + await controller.observeOpened(x); + final pendingA = LocalHistoryPathReconciliation.remap( + operationId: 'pending-a', + sourcePath: a.filePath!, + destinationPath: '/workspace/B.md', + targets: await store.resolvePathTargets(a.filePath!, recursive: false), + ); + final pendingX = LocalHistoryPathReconciliation.remap( + operationId: 'pending-x', + sourcePath: x.filePath!, + destinationPath: '/workspace/Y.md', + targets: await store.resolvePathTargets(x.filePath!, recursive: false), + ); + store.failingReconciliationSources.addAll([ + pendingA.sourcePath, + pendingX.sourcePath, + ]); + expect( + await controller.restorePendingPathReconciliations([ + pendingA, + pendingX, + ]), + isFalse, + ); + final retainedA = controller.pendingPathReconciliations.first; + store.reconciliationAttempts.clear(); + var filesystemCalls = 0; + Future moveY() => controller.runStagedPathRemap( + sourcePath: '/workspace/Y.md', + destinationPath: '/workspace/Z.md', + filesystemOperation: () async { + filesystemCalls++; + return true; + }, + didCommit: (value) => value, + ); + await expectLater(moveY(), throwsA(isA())); + expect(filesystemCalls, 0); + expect(store.reconciliationAttempts, [pendingX.sourcePath]); + expect( + controller.pendingPathReconciliations.map((op) => op.operationId), + ['pending-a', 'pending-x'], + ); + store.failingReconciliationSources.remove(pendingX.sourcePath); + store.reconciliationAttempts.clear(); + expect(await moveY(), isTrue); + expect(filesystemCalls, 1); + expect(store.reconciliationAttempts, [ + pendingX.sourcePath, + '/workspace/Y.md', + ]); + expect( + controller.pendingPathReconciliations.first.toJson(), + retainedA.toJson(), + ); + final snapshot = await store.load(); + expect( + snapshot.documents + .singleWhere((d) => d.id == pendingA.documentIds.single) + .currentPath, + a.filePath, + ); + expect( + snapshot.documents + .singleWhere((d) => d.id == pendingX.documentIds.single) + .currentPath, + '/workspace/Z.md', + ); + }, + ); + + for (final promotion in [false, true]) { + test( + 'path dependency recomputes identities after settling namespace prerequisites ' + '(promotion=$promotion)', + () async { + final store = _ControllableRemapStore(); + final container = _historyContainer(store, <_FakeTimer>[]); + addTearDown(container.dispose); + final controller = container.read( + localHistoryControllerProvider.notifier, + ); + await Future.delayed(Duration.zero); + final a = _fileBuffer( + 'old-path-owner', + '/workspace/A.md', + 'moved identity', + ); + await controller.observeOpened(a); + final target = (await store.resolvePathTargets( + a.filePath!, + recursive: false, + )).single; + final first = LocalHistoryPathReconciliation.remap( + operationId: 'move-a-to-b', + sourcePath: '/workspace/A.md', + destinationPath: '/workspace/B.md', + targets: [target], + ); + final second = LocalHistoryPathReconciliation.remap( + operationId: 'move-b-to-d', + sourcePath: '/workspace/B.md', + destinationPath: '/workspace/D.md', + targets: [target.atPath('/workspace/B.md')], + ); + store.failingReconciliationSources.addAll([ + first.sourcePath, + second.sourcePath, + ]); + expect( + await controller.restorePendingPathReconciliations([first, second]), + isFalse, + ); + store.failingReconciliationSources.remove(first.sourcePath); + store.reconciliationAttempts.clear(); + var filesystemCalled = false; + if (promotion) { + final untitled = DocumentBuffer.untitled( + id: 'new-owner', + name: 'New', + text: 'new independent identity', + ); + await controller.observeOpened(untitled); + expect( + await controller.captureSavedAs( + LocalHistoryBufferSnapshot.fromBuffer(untitled), + a.filePath!, + destinationExisted: false, + ), + isTrue, + ); + final created = (await store.load()).documents.singleWhere( + (d) => d.currentPath == a.filePath, + ); + expect(created.id, isNot(target.documentId)); + expect(controller.documentIdForBuffer(untitled.id), created.id); + } else { + expect( + await controller.runStagedPathDeletion( + path: '/workspace/A.md', + recursive: false, + filesystemOperation: () async { + filesystemCalled = true; + return true; + }, + didCommit: (value) => value, + ), + isTrue, + ); + expect(filesystemCalled, isTrue); + } + expect( + store.reconciliationAttempts.where( + (path) => path == second.sourcePath, + ), + isEmpty, + ); + expect( + controller.pendingPathReconciliations.single.operationId, + second.operationId, + ); + expect(controller.pendingPathReconciliations.single.documentIds, [ + target.documentId, + ]); + final document = (await store.load()).documents.singleWhere( + (d) => d.id == target.documentId, + ); + expect(document.id, target.documentId); + expect(document.currentPath, '/workspace/B.md'); + expect(document.deleted, isFalse); + }, + ); + } + + test('path dependency preserves transitive queue ordering', () async { + final store = _ControllableRemapStore(); + final container = _historyContainer(store, <_FakeTimer>[]); + addTearDown(container.dispose); + final controller = container.read(localHistoryControllerProvider.notifier); + await Future.delayed(Duration.zero); + final a = _fileBuffer('chain-owner', '/workspace/A.md', 'chain history'); + await controller.observeOpened(a); + final target = (await store.resolvePathTargets( + a.filePath!, + recursive: false, + )).single; + final first = LocalHistoryPathReconciliation.remap( + operationId: 'chain-first', + sourcePath: '/workspace/A.md', + destinationPath: '/workspace/B.md', + targets: [target], + ); + final second = LocalHistoryPathReconciliation.remap( + operationId: 'chain-second', + sourcePath: '/workspace/B.md', + destinationPath: '/workspace/C.md', + targets: [target.atPath('/workspace/B.md')], + ); + store.failingReconciliationSources.add(first.sourcePath); + expect( + await controller.restorePendingPathReconciliations([first, second]), + isFalse, + ); + store.reconciliationAttempts.clear(); + var filesystemCalled = false; + Future deleteC() => controller.runStagedPathDeletion( + path: '/workspace/C.md', + recursive: false, + filesystemOperation: () async { + filesystemCalled = true; + return true; + }, + didCommit: (value) => value, + ); + await expectLater(deleteC(), throwsA(isA())); + expect(filesystemCalled, isFalse); + expect(store.reconciliationAttempts, [first.sourcePath]); + expect(controller.pendingPathReconciliations.map((op) => op.operationId), [ + 'chain-first', + 'chain-second', + ]); + store.failingReconciliationSources.clear(); + expect(await deleteC(), isTrue); + expect((await store.load()).documents.single.deleted, isTrue); + expect( + (await store.load()).documents.single.currentPath, + '/workspace/C.md', + ); + }); + + for (final executingHead in [false, true]) { + test('path dependency retries independent work behind retained head ' + '(executing=$executingHead)', () async { + final store = _ControllableRemapStore(); + final timers = <_FakeTimer>[]; + final container = _historyContainer(store, timers); + addTearDown(container.dispose); + final controller = container.read( + localHistoryControllerProvider.notifier, + ); + await Future.delayed(Duration.zero); + if (executingHead) { + expect( + await controller.restorePendingPathReconciliations([ + const LocalHistoryPathReconciliation.remap( + operationId: 'executing-head', + sourcePath: '/workspace/A.md', + destinationPath: '/workspace/B.md', + targets: [], + ownerIds: ['unknown-outcome-owner'], + phase: LocalHistoryPathReconciliationPhase.executing, + ), + ]), + isFalse, + ); + } else { + final a = _fileBuffer( + 'applied-head-owner', + '/workspace/A.md', + 'A history', + ); + await controller.observeOpened(a); + expect( + await controller.runStagedPathRemap( + sourcePath: a.filePath!, + destinationPath: '/workspace/B.md', + filesystemOperation: () async => true, + didCommit: (value) => value, + ), + isTrue, + ); + } + final head = controller.pendingPathReconciliations.single; + final x = _fileBuffer('retry-owner', '/workspace/X.md', 'X history'); + await controller.observeOpened(x); + final deletion = LocalHistoryPathReconciliation.deletion( + operationId: 'retry-independent', + sourcePath: x.filePath!, + recursive: false, + targets: await store.resolvePathTargets(x.filePath!, recursive: false), + ); + store.failingReconciliationSources.add(deletion.sourcePath); + expect( + await controller.restorePendingPathReconciliations([deletion]), + isFalse, + ); + expect(controller.pendingPathReconciliations, hasLength(2)); + store.failingReconciliationSources.clear(); + store.reconciliationAttempts.clear(); + for (final timer in timers.toList()) { + timer.fire(); + } + await _waitForHistory( + container, + (_) => controller.pendingPathReconciliations.length == 1, + ); + expect(store.reconciliationAttempts, [deletion.sourcePath]); + expect( + controller.pendingPathReconciliations.single.toJson(), + head.toJson(), + ); + expect( + (await store.load()).documents + .singleWhere((d) => d.currentPath == x.filePath) + .deleted, + isTrue, + ); + }); + } + test( 'staged path commit survives refresh failure until workspace acknowledgement', () async { @@ -5409,6 +6080,8 @@ class _RepairStore extends MemoryLocalHistoryStore { } class _ControllableRemapStore extends MemoryLocalHistoryStore { + final failingReconciliationSources = {}; + final reconciliationAttempts = []; var failRemaps = false; var blockNextRemap = false; var failDeletions = false; @@ -5458,6 +6131,12 @@ class _ControllableRemapStore extends MemoryLocalHistoryStore { Future reconcilePath( LocalHistoryPathReconciliation reconciliation, ) async { + reconciliationAttempts.add(reconciliation.sourcePath); + if (failingReconciliationSources.contains(reconciliation.sourcePath)) { + throw const LocalHistoryStorageException( + 'Injected path-specific reconciliation failure', + ); + } if (reconciliation.kind == LocalHistoryPathReconciliationKind.remap) { remapAttempts++; if (blockNextRemap) { diff --git a/test/src/local_history_workspace_test.dart b/test/src/local_history_workspace_test.dart index ea7be97b..c00262aa 100644 --- a/test/src/local_history_workspace_test.dart +++ b/test/src/local_history_workspace_test.dart @@ -15,6 +15,7 @@ import 'package:busymark/src/workspace/session_persistence.dart'; import 'package:busymark/src/workspace/text_format_metadata.dart'; import 'package:busymark/src/workspace/workspace_controller.dart'; import 'package:busymark/src/workspace/workspace_file_monitor.dart'; +import 'package:busymark/src/workspace/workspace_message.dart'; import 'package:busymark/src/workspace/workspace_model.dart'; import 'package:busymark/src/workspace/workspace_service.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -3331,6 +3332,169 @@ void main() { }, ); + for (final existingDestination in [false, true]) { + test('pending unrelated remap permits workspace Save As ' + '(existing=$existingDestination)', () async { + final source = p.join(root.path, 'A.md'); + final moved = p.join(root.path, 'B.md'); + final destination = p.join(root.path, 'C.md'); + const sourceText = 'History belonging only to A/B\n'; + const destinationText = 'Existing history belonging only to C\n'; + const newText = 'Distinctive newly created C contents\n'; + await File(source).writeAsString(sourceText); + final historyRoot = Directory(p.join(root.path, 'history')); + FileLocalHistoryStore freshStore() => + FileLocalHistoryStore(rootDirectory: () async => historyRoot); + final diskStore = freshStore(); + final existing = existingDestination + ? await _capture(diskStore, destination, destinationText) + : null; + if (existingDestination) { + await File(destination).writeAsString(destinationText); + } + final store = _ControllableHistoryStore(diskStore); + final sessions = MemoryDocumentSessionStore(); + final harness = await _harness( + store, + sessionStore: sessions, + recoveryStore: MemoryDocumentRecoveryStore(), + fileMonitor: _ControlledFileMonitor(), + timerFactory: (delay, callback) => _FakeTimer(delay, callback), + ); + final history = harness.container.read( + localHistoryControllerProvider.notifier, + ); + await harness.controller.openPath(source); + expect(await history.flushBuffer(harness.state.activeBuffer!), isTrue); + final sourceId = history.documentIdForBuffer( + harness.state.activeBuffer!.id, + )!; + store.remapsAvailable = false; + expect( + await harness.controller.renameWorkspaceEntity(source, 'B.md'), + isTrue, + ); + expect(await File(source).exists(), isFalse); + expect(await File(moved).readAsString(), sourceText); + var pending = history.pendingPathReconciliations.single; + expect(pending.sourcePath, source); + expect(pending.destinationPath, moved); + expect(pending.documentIds, [sourceId]); + expect(pending.phase, LocalHistoryPathReconciliationPhase.committed); + + expect(await harness.controller.createMarkdownWorkspace(), isTrue); + harness.controller.updateActiveText(newText); + final newBufferId = harness.state.activeBuffer!.id; + expect(await history.flushBuffer(harness.state.activeBuffer!), isTrue); + final untitledId = history.documentIdForBuffer(newBufferId)!; + expect(untitledId, isNot(sourceId)); + // Closing A transfers its retained work to a detached owner. + pending = history.pendingPathReconciliations.single; + expect( + await harness.controller.saveActiveAs( + destination, + overwriteExisting: existingDestination, + ), + isTrue, + ); + expect(await File(destination).readAsString(), newText); + expect(harness.state.activeBuffer!.filePath, destination); + expect(harness.state.activeBuffer!.isUntitled, isFalse); + expect(harness.state.activeBuffer!.isDirty, isFalse); + expect( + harness.state.message?.code, + isNot(WorkspaceMessageCode.saveFailed), + ); + final destinationId = history.documentIdForBuffer(newBufferId)!; + expect(destinationId, existing?.document.id ?? untitledId); + expect(destinationId, isNot(sourceId)); + if (existingDestination) expect(destinationId, isNot(untitledId)); + expect(history.warningForBuffer(newBufferId), isNull); + expect( + history.pendingPathReconciliations.single.toJson(), + pending.toJson(), + ); + await harness.controller.flushPersistence(); + expect( + sessions.value!.pendingLocalHistoryReconciliations.single.toJson(), + pending.toJson(), + ); + + final beforeRecoveryStore = freshStore(); + final beforeRecovery = await beforeRecoveryStore.load(); + expect( + beforeRecovery.documents + .singleWhere((d) => d.id == sourceId) + .currentPath, + source, + ); + expect( + beforeRecovery.documents + .singleWhere((d) => d.id == destinationId) + .currentPath, + destination, + ); + final destinationRevisions = beforeRecovery.revisionsFor(destinationId); + expect( + await _revisionSources(beforeRecoveryStore, destinationRevisions), + existingDestination + ? containsAll([destinationText, newText]) + : everyElement(newText), + ); + expect(destinationRevisions, isNotEmpty); + expect( + await _revisionSources( + beforeRecoveryStore, + beforeRecovery.revisionsFor(sourceId), + ), + [sourceText], + ); + if (existingDestination) { + expect( + beforeRecovery.documents + .singleWhere((d) => d.id == untitledId) + .untitled, + isTrue, + ); + expect( + await _revisionSources( + beforeRecoveryStore, + beforeRecovery.revisionsFor(untitledId), + ), + [newText], + ); + } + + store.remapsAvailable = true; + expect(await history.flushAll(harness.state.documentBuffers), isTrue); + expect(history.pendingPathReconciliations, isEmpty); + final afterRecoveryStore = freshStore(); + final afterRecovery = await afterRecoveryStore.load(); + expect( + afterRecovery.documents + .singleWhere((d) => d.id == sourceId) + .currentPath, + moved, + ); + expect( + afterRecovery.documents + .singleWhere((d) => d.id == destinationId) + .toJson(), + beforeRecovery.documents + .singleWhere((d) => d.id == destinationId) + .toJson(), + ); + expect( + afterRecovery.revisionsFor(destinationId).map((r) => r.toJson()), + destinationRevisions.map((r) => r.toJson()), + ); + expect(history.documentIdForBuffer(newBufferId), destinationId); + expect(harness.state.activeBuffer!.filePath, destination); + expect(await File(destination).readAsString(), newText); + expect(history.warningForBuffer(newBufferId), isNull); + }); + } + test('committed move retries a failed history path reconciliation', () async { final sourcePath = p.join(root.path, 'remap-a.md'); final destinationPath = p.join(root.path, 'remap-b.md'); @@ -4173,6 +4337,8 @@ void main() { ); final owner = await _harness( store, + // Keep this staged remap unreconciled while testing live-owner recovery. + fileMonitor: _ControlledFileMonitor(), sessionStore: sessions, recoveryStore: ownerRecovery, ); @@ -4200,6 +4366,7 @@ void main() { sessions.value?.pendingLocalHistoryReconciliations.single.phase, LocalHistoryPathReconciliationPhase.committed, ); + expect(owner.state.activeBuffer!.filePath, source); final observerRecovery = _ExternallyOwnedJsonRecoveryStore( filePathOverride: recoveryFile, diff --git a/test/src/markdown_serializer_metadata_test.dart b/test/src/markdown_serializer_metadata_test.dart index 554457a7..64fde130 100644 --- a/test/src/markdown_serializer_metadata_test.dart +++ b/test/src/markdown_serializer_metadata_test.dart @@ -10,6 +10,71 @@ import 'package:flutter_test/flutter_test.dart'; void main() { const serializer = BusyMarkMarkdownSerializer(); + test( + 'reconstructed formatting across links retains offsets and leaf metadata', + () { + for (final kind in [BusyInlineKind.strong, BusyInlineKind.emphasis]) { + final block = busyMarkWysiwygClipboardBlock( + BusyWysiwygStyledBlock( + kind: BusyBlockKind.paragraph, + text: 'hello world ', + ranges: [ + BusyInlineStyleRange(start: 0, end: 11, kind: kind), + const BusyInlineStyleRange( + start: 6, + end: 11, + kind: BusyInlineKind.link, + destination: 'guide.md', + attributes: {'title': 'Guide', 'target': '_blank'}, + ), + ], + ), + ); + final delimiter = kind == BusyInlineKind.strong ? '**' : '*'; + final expected = + '${delimiter}hello [world](guide.md "Guide")$delimiter '; + for (var offset = 0; offset <= block.plainText.length; offset++) { + final result = serializer.serializeInlineFragmentWithOffsets( + block.inlines, + textOffset: offset, + ); + expect(result.source, expected); + final expectedOffset = offset == 0 + ? 0 + : offset < 6 + ? delimiter.length + offset + : offset == 6 + ? delimiter.length + 6 + : offset < 11 + ? delimiter.length + offset + 1 + : offset == 11 + ? expected.length - 1 + : expected.length; + expect( + result.sourceOffset, + expectedOffset, + reason: '$kind offset $offset', + ); + expect( + result.textAtoms.map((atom) => atom.text).join(), + 'hello world ', + ); + for (final atom in result.textAtoms) { + expect( + expected.substring(atom.sourceStart, atom.sourceEnd), + atom.text, + ); + } + } + final link = busyInlineStyleRanges( + block.inlines, + ).singleWhere((range) => range.kind == BusyInlineKind.link); + expect((link.start, link.end, link.destination), (6, 11, 'guide.md')); + expect(link.attributes, {'title': 'Guide', 'target': '_blank'}); + } + }, + ); + test( 'partially formatted URL source offsets and text atoms survive save and edit', () { diff --git a/test/src/nextcloud_notes/notes_creation_policy_test.dart b/test/src/nextcloud_notes/notes_creation_policy_test.dart new file mode 100644 index 00000000..2d30b63e --- /dev/null +++ b/test/src/nextcloud_notes/notes_creation_policy_test.dart @@ -0,0 +1,370 @@ +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_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 serverNote, testAccount; + +void main() { + late Directory root; + late NotesRepository repository; + late MockClient transport; + final remotes = >{}; + var creates = 0; + var puts = 0; + var uploads = 0; + var deliverCreate = true; + Future Function(http.Request)? getCandidate; + Future reopen() async { + repository = NotesRepository( + store: await NotesStore.open(path: '${root.path}/notes.sqlite3'), + clientForAccount: (account) async => NotesApiClient( + client: transport, + account: account, + appPassword: 'test', + ), + ); + await repository.initialize(); + if (repository.accounts.isEmpty) { + await repository.upsertAccount(testAccount()); + } + } + + Future uncertain({String content = 'submitted'}) async { + final note = await repository.create(testAccount().id, content: content); + await repository.synchronize(note.accountId); + expect( + repository.noteById(note.localId)!.syncState, + NoteSyncState.creationUncertain, + ); + return repository.noteById(note.localId)!; + } + + NotesCreationReview review(String id) => + repository.creationReview(id, repository.noteById(id)!.remote!); + Future adopt(NotesCreationReview reviewed) => + repository.resolveConflict( + reviewed.localId, + NoteConflictResolution.useServerNote, + expectedRevision: reviewed.revision, + creationCandidateServerId: reviewed.candidate.id, + creationReview: reviewed, + ); + setUp(() async { + root = await Directory.systemTemp.createTemp('creation-policy-'); + remotes.clear(); + creates = puts = uploads = 0; + deliverCreate = true; + getCandidate = null; + transport = MockClient((request) async { + if (request.method == 'POST' && request.url.path.endsWith('/notes')) { + creates++; + if (deliverCreate) { + remotes[1] = {...serverNote(1), ...jsonDecode(request.body) as Map}; + } + throw http.ClientException('lost response'); + } + if (request.method == 'GET' && request.url.path.endsWith('/notes')) { + return http.Response(jsonEncode(remotes.values.toList()), 200); + } + if (request.method == 'GET') { + if (getCandidate != null) return getCandidate!(request); + final remote = remotes[int.parse(request.url.pathSegments.last)]; + return http.Response( + remote == null ? '{}' : jsonEncode(remote), + remote == null ? 404 : 200, + ); + } + if (request.method == 'PUT') { + puts++; + final id = int.parse(request.url.pathSegments.last); + expect(request.headers['If-Match'], '"${remotes[id]!['etag']}"'); + remotes[id] = { + ...remotes[id]!, + ...jsonDecode(request.body) as Map, + 'etag': 'updated-$puts', + }; + return http.Response(jsonEncode(remotes[id]), 200); + } + if (request.method == 'POST') { + uploads++; + return http.Response('{"filename":".attachments.1/photo.png"}', 200); + } + fail('Unexpected mutation: ${request.method}'); + }); + await reopen(); + }); + tearDown(() async { + await repository.dispose(); + await root.delete(recursive: true); + }); + + for (final scenario in [ + 'exact', + 'sanitized', + 'multiple', + 'none', + 'empty', + 'other actor', + ]) { + test( + '$scenario discovery across refresh reconnect and restart cannot bind or mutate draft', + () async { + deliverCreate = scenario != 'other actor'; + final note = await uncertain( + content: scenario == 'empty' ? '' : 'submitted', + ); + final attachment = await repository.addAttachment( + note.localId, + filename: 'photo.png', + bytes: Uint8List.fromList([1, 2, 3]), + ); + await repository.save( + note.localId, + content: '${note.content}\n![photo](${attachment.reference})', + ); + final immutable = note.creationAttempt!.wireBody; + if (scenario == 'other actor') { + remotes[1] = {...serverNote(1), ...note.creationAttempt!.attributes}; + } + if (scenario == 'none') remotes.clear(); + if (scenario == 'sanitized') remotes[1]!['title'] = 'Sanitized'; + if (scenario == 'multiple') remotes[2] = {...remotes[1]!, 'id': 2}; + final before = jsonEncode(remotes.values.toList()); + for (var i = 0; i < 3; i++) { + await repository.synchronize(note.accountId); + await repository.dispose(); + await reopen(); + await repository.upsertAccount(testAccount()); + final pending = repository.noteById(note.localId)!; + expect(pending.serverId, isNull); + expect(pending.ackRevision, 0); + expect(pending.creationAttempt!.id, note.creationAttempt!.id); + expect(pending.creationAttempt!.wireBody, immutable); + expect( + pending.creationAttempt!.candidateServerIds, + scenario == 'none' + ? {} + : scenario == 'multiple' + ? {1, 2} + : {1}, + ); + expect(pending.content, contains('busymark-attachment:')); + expect(await repository.store.attachmentBytes(attachment.id), [ + 1, + 2, + 3, + ]); + await expectLater( + repository.delete(note.localId), + throwsA(isA()), + ); + await expectLater( + repository.retryUncertainAttachments(note.localId), + throwsA(isA()), + ); + } + expect(creates, 1); + expect(puts, 0); + expect(uploads, 0); + expect(jsonEncode(remotes.values.toList()), before); + // The unresolved draft cannot stop an independently pending update. + remotes[9] = serverNote(9); + await repository.synchronize(note.accountId); + final other = repository.notes.firstWhere((n) => n.serverId == 9); + await repository.save(other.localId, content: 'unrelated edit'); + await repository.synchronize(note.accountId); + expect(remotes[9]!['content'], 'unrelated edit'); + expect(repository.noteById(note.localId)!.serverId, isNull); + expect(puts, 1); + }, + ); + } + + for (final change in [ + 'content', + 'readonly', + 'error', + 'deleted', + 'unavailable', + ]) { + test( + 'confirmation rejects $change candidate and retains unresolved work', + () async { + final note = await uncertain(); + await repository.synchronize(note.accountId); + final reviewed = review(note.localId); + switch (change) { + case 'content': + remotes[1]!['content'] = 'external edit'; + case 'readonly': + remotes[1]!['readonly'] = true; + case 'error': + remotes[1]!['error'] = true; + case 'deleted': + remotes.remove(1); + case 'unavailable': + getCandidate = (_) async => http.Response('{}', 503); + } + await expectLater(adopt(reviewed), throwsA(isA())); + final pending = repository.noteById(note.localId)!; + expect(pending.serverId, isNull); + expect(pending.content, 'submitted'); + expect(pending.creationAttempt!.id, reviewed.attemptId); + expect(puts, 0); + expect(creates, 1); + if (change == 'content') { + expect(pending.remote!.content, 'external edit'); + } + }, + ); + } + + for (final race in ['local edit', 'account removal', 'repeat confirmation']) { + test('confirmation revalidates $race during authenticated GET', () async { + final note = await uncertain(); + await repository.synchronize(note.accountId); + final reviewed = review(note.localId); + final entered = Completer(); + final response = Completer(); + getCandidate = (_) { + entered.complete(); + return response.future; + }; + final confirmation = adopt(reviewed); + final check = race == 'repeat confirmation' + ? expectLater(confirmation, completes) + : expectLater(confirmation, throwsA(isA())); + await entered.future; + switch (race) { + case 'local edit': + await repository.save(note.localId, content: 'new local revision'); + case 'account removal': + await repository.removeAccount(note.accountId); + case 'repeat confirmation': + await expectLater(adopt(reviewed), throwsA(isA())); + } + response.complete(http.Response(jsonEncode(remotes[1]), 200)); + await check; + if (race == 'local edit') { + expect( + repository.noteById(note.localId)!.content, + 'new local revision', + ); + expect(repository.noteById(note.localId)!.serverId, isNull); + } + if (race == 'account removal') expect(repository.notes, isEmpty); + if (race == 'repeat confirmation') { + await expectLater(adopt(reviewed), throwsA(isA())); + expect(repository.noteById(note.localId)!.serverId, 1); + } + expect(puts, 0); + }); + } + + for (final competing in [ + 'durable edit', + 'pending attachment', + 'open editor', + ]) { + test('binding protects downloaded candidate with $competing', () async { + final note = await uncertain(); + await repository.synchronize(note.accountId); + final reviewed = review(note.localId); + final twin = repository.notes.firstWhere((n) => n.serverId == 1); + if (competing == 'durable edit') { + await repository.save(twin.localId, content: 'twin edit'); + } + if (competing == 'pending attachment') { + await repository.addAttachment( + twin.localId, + filename: 'photo.png', + bytes: Uint8List.fromList([4, 5]), + ); + } + if (competing == 'open editor') { + repository.addCreationBindingGuard( + (localId, ids) => !ids.contains(twin.localId), + ); + } + await expectLater(adopt(reviewed), throwsA(isA())); + expect(repository.noteById(note.localId)!.serverId, isNull); + expect(repository.noteById(twin.localId), isNotNull); + await repository.dispose(); + await reopen(); + expect(repository.noteById(twin.localId), isNotNull); + }); + } + + test( + 'failed adoption transaction rolls back binding and downloaded identity', + () async { + final note = await uncertain(); + await repository.synchronize(note.accountId); + final reviewed = review(note.localId); + final twin = repository.notes.firstWhere((n) => n.serverId == 1); + final db = sqlite3.open(repository.store.path); + db.execute( + "CREATE TRIGGER fail_binding BEFORE UPDATE ON notes WHEN NEW.server_id=1 AND NEW.id='${note.localId}' BEGIN SELECT RAISE(ABORT,'fixture'); END", + ); + await expectLater(adopt(reviewed), throwsA(isA())); + expect(repository.noteById(note.localId)!.serverId, isNull); + expect(repository.noteById(twin.localId), isNotNull); + db.execute('DROP TRIGGER fail_binding'); + db.close(); + await repository.dispose(); + await reopen(); + expect(repository.noteById(note.localId)!.serverId, isNull); + expect(repository.noteById(twin.localId), isNotNull); + await adopt(review(note.localId)); + await repository.dispose(); + await reopen(); + expect(repository.noteById(note.localId)!.serverId, 1); + expect(repository.noteById(twin.localId), isNull); + expect(repository.noteById(note.localId)!.creationAttempt, isNull); + }, + ); + + test( + 'separate-note decision is durable and repeated confirmation makes one replacement', + () async { + final note = await uncertain(); + await repository.synchronize(note.accountId); + await repository.resolveConflict( + note.localId, + NoteConflictResolution.saveAsNew, + ); + await repository.resolveConflict( + note.localId, + NoteConflictResolution.saveAsNew, + ); + final separateId = repository + .noteById(note.localId)! + .creationAttempt! + .separateNoteLocalId; + expect(separateId, isNotNull); + expect(repository.notes, hasLength(3)); + await repository.dispose(); + await reopen(); + await repository.resolveConflict( + note.localId, + NoteConflictResolution.saveAsNew, + ); + expect(repository.notes, hasLength(3)); + expect(repository.noteById(separateId!)!.content, 'submitted'); + await expectLater( + adopt(review(note.localId)), + throwsA(isA()), + ); + }, + ); +} diff --git a/test/src/nextcloud_notes/notes_repository_test.dart b/test/src/nextcloud_notes/notes_repository_test.dart index 8897eb2c..a68ab2db 100644 --- a/test/src/nextcloud_notes/notes_repository_test.dart +++ b/test/src/nextcloud_notes/notes_repository_test.dart @@ -86,7 +86,7 @@ void main() { ); test( - 'uncertain creation adopts wire revision and preserves later edits', + 'uncertain creation discovery preserves later edits until deliberate adoption', () async { Map? remote; var posts = 0; @@ -109,7 +109,9 @@ void main() { } return http.Response( jsonEncode( - request.method == 'GET' ? [if (remote != null) remote] : remote, + request.method == 'GET' && request.url.path.endsWith('/notes') + ? [if (remote != null) remote] + : remote, ), 200, ); @@ -121,6 +123,25 @@ void main() { ); await repository.synchronize(local.accountId); await repository.save(local.localId, content: 'newer local content'); + for (var i = 0; i < 3; i++) { + await repository.synchronize(local.accountId); + } + final unresolved = repository.noteById(local.localId)!; + expect(unresolved.serverId, isNull); + expect(unresolved.syncState, NoteSyncState.creationUncertain); + expect(unresolved.creationAttempt!.candidateServerIds, {1}); + expect(unresolved.ackRevision, 0); + expect(posts, 1); + expect(puts, 0); + await repository.resolveConflict( + local.localId, + NoteConflictResolution.useServerNote, + creationCandidateServerId: 1, + creationReview: repository.creationReview( + local.localId, + repository.noteById(local.localId)!.remote!, + ), + ); await repository.synchronize(local.accountId); expect( repository.noteById(local.localId)!.content, @@ -203,7 +224,7 @@ void main() { 'ambiguous', ]) { test( - 'lost create response reconciles $scenario without another POST', + 'lost create response requires deliberate adoption for $scenario', () async { late NotesRepository repository; Map? remote; @@ -212,11 +233,15 @@ void main() { final client = MockClient((request) async { if (request.method == 'GET') { return http.Response( - jsonEncode([ - if (remote != null) remote, - if (remote != null && scenario == 'ambiguous') - {...remote!, 'id': 2}, - ]), + jsonEncode( + request.url.path.endsWith('/notes') + ? [ + if (remote != null) remote, + if (remote != null && scenario == 'ambiguous') + {...remote!, 'id': 2}, + ] + : remote, + ), 200, ); } @@ -299,9 +324,7 @@ void main() { repository = await open(client, addAccount: false); reconciled = repository.noteById(note.localId)!; } - final requiresAdoption = - scenario.contains('sanitized') || scenario == 'ambiguous'; - if (requiresAdoption) { + { expect(reconciled.serverId, isNull); expect(reconciled.syncState, NoteSyncState.creationUncertain); expect( @@ -311,8 +334,15 @@ void main() { expect(reconciled.remote?.id, scenario == 'ambiguous' ? isNull : 1); await repository.resolveConflict( note.localId, - NoteConflictResolution.takeRemote, - creationCandidateServerId: scenario == 'ambiguous' ? 1 : null, + NoteConflictResolution.useServerNote, + creationCandidateServerId: 1, + creationReview: repository.creationReview( + note.localId, + repository + .uncertainCreationCandidates(note.localId) + .firstWhere((n) => n.serverId == 1) + .base!, + ), ); await repository.synchronize(note.accountId); reconciled = repository.noteById(note.localId)!; @@ -1989,7 +2019,9 @@ void main() { await store.commit(notes: [original, twin]); await store.close(); final repository = await open( - MockClient((_) async => throw StateError('Adoption needs no HTTP')), + MockClient( + (_) async => http.Response(jsonEncode(remote.toJson()), 200), + ), addAccount: false, ); await repository.save( diff --git a/test/src/nextcloud_notes_ui_test.dart b/test/src/nextcloud_notes_ui_test.dart index 263a4e20..86d902c7 100644 --- a/test/src/nextcloud_notes_ui_test.dart +++ b/test/src/nextcloud_notes_ui_test.dart @@ -1,4 +1,9 @@ import 'dart:io'; +import 'dart:convert'; + +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:busymark/src/nextcloud_notes/data/notes_api_client.dart'; import 'package:busymark/l10n/generated/app_localizations.dart'; import 'package:busymark/src/app/app_settings.dart'; @@ -18,7 +23,9 @@ 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'; import 'package:busymark/src/workspace/workspace_controller.dart'; +import 'package:busymark/src/workspace/workspace_model.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/gestures.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -365,6 +372,135 @@ void main() { await tester.pumpWidget(const SizedBox.shrink()); }); + for (final action in ['middle', 'other', 'all']) { + testWidgets( + 'remote tab $action closing keeps notes and disables Copy path', + (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); + final controller = container.read(workspaceControllerProvider.notifier); + const personalId = '223e4567-e89b-42d3-a456-426614174000'; + const sharedId = '323e4567-e89b-42d3-a456-426614174000'; + await tester.runAsync(() async { + await controller.openNextcloudWorkspace(accountId); + await controller.openNextcloudNote(personalId); + await controller.openNextcloudNote(sharedId); + }); + container.read(appRouterProvider).go('/workspace'); + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const BusyMarkApp(), + ), + ); + await tester.pumpAndSettle(); + final target = container + .read(workspaceControllerProvider) + .documentBuffers + .singleWhere((buffer) => buffer.remoteNote?.localId == personalId); + final activeId = container + .read(workspaceControllerProvider) + .activeBufferId; + Finder label() => find + .descendant( + of: find.byKey(ValueKey('file:${target.id}')), + matching: find.byType(Text), + ) + .first; + Map? menu; + var choice = 4; // Disabled Copy path must not dispatch or write. + var clipboardWrites = 0; + const channel = MethodChannel('busymark/native_menus'); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + channel, + (call) async { + if (call.method == 'show') { + menu = call.arguments as Map; + return choice; + } + return true; + }, + ); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + (call) async { + if (call.method == 'Clipboard.setData') clipboardWrites++; + return null; + }, + ); + addTearDown(() { + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + channel, + null, + ); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + null, + ); + }); + await tester.tap(label(), buttons: kSecondaryButton); + await tester.pumpAndSettle(); + final entries = menu!['entries'] as List; + expect((entries[4] as Map)['enabled'], isFalse); + expect(clipboardWrites, 0); + expect( + container.read(workspaceControllerProvider).activeBufferId, + activeId, + ); + if (action == 'middle') { + await tester.tap(label(), buttons: kTertiaryButton); + } else { + choice = action == 'other' ? 1 : 2; + await tester.tap(label(), buttons: kSecondaryButton); + } + for (var i = 0; i < 100; i++) { + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 10)), + ); + await tester.pump(const Duration(milliseconds: 10)); + if (container + .read(workspaceControllerProvider) + .documentBuffers + .length == + (action == 'all' ? 0 : 1)) { + break; + } + } + await tester.pumpAndSettle(); + final state = container.read(workspaceControllerProvider); + expect(state.documentBuffers, hasLength(action == 'all' ? 0 : 1)); + if (action == 'other') expect(state.activeBufferId, target.id); + if (action == 'middle') { + expect(state.documentBuffers.single.remoteNote?.localId, sharedId); + } + expect(state.workspace!.kind, WorkspaceKind.nextcloudNotes); + expect((repository as _CachedNotesRepository).deleteCalls, 0); + expect(repository.noteById(personalId)?.content, 'private search text'); + expect(repository.noteById(sharedId)?.content, 'shared material'); + expect(clipboardWrites, 0); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox.shrink()); + }, + ); + } + testWidgets( 'cached note list searches content and excludes clean deletion tombstones', (tester) async { @@ -733,7 +869,8 @@ void main() { await tester.pumpAndSettle(); final takeRemote = find.byWidgetPredicate( (widget) => - widget is BusyMarkDialogButton && widget.label == 'Take remote', + widget is BusyMarkDialogButton && + widget.label == 'Use this server note', ); expect(takeRemote, findsNothing); final candidates = find.byType(DropdownButton); @@ -744,9 +881,111 @@ void main() { expect(find.textContaining('Sanitized A'), findsWidgets); await tester.tap(find.text('Cancel')); await tester.pumpAndSettle(); + expect(repository.noteById('uncertain-create')!.serverId, isNull); expect(tester.takeException(), isNull); }); + testWidgets( + 'selecting and confirming candidate binds draft and retains newer edits', + (tester) async { + for (final channelName in ['yaru_window', 'yaru_window/events']) { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + MethodChannel(channelName), + (call) async => + call.method == 'state' ? {} : null, + ); + } + await tester.runAsync( + () => repository.save( + 'uncertain-create', + content: 'newer editor content', + ), + ); + await tester.pumpWidget( + ProviderScope( + overrides: [ + nextcloudNotesRepositoryProvider.overrideWith( + (ref) async => repository, + ), + localSettingsStoreProvider.overrideWithValue(_TabSettings()), + localHistoryStoreProvider.overrideWithValue( + MemoryLocalHistoryStore(), + ), + documentSessionStoreProvider.overrideWithValue( + MemoryDocumentSessionStore(), + ), + documentRecoveryStoreProvider.overrideWithValue( + MemoryDocumentRecoveryStore(), + ), + ], + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const Scaffold( + body: NextcloudNoteStatus( + localId: 'uncertain-create', + unsaved: false, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.runAsync(() => tester.tap(find.text('Compare'))); + for ( + var i = 0; + i < 100 && find.byType(DropdownButton).evaluate().isEmpty; + i++ + ) { + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 10)), + ); + await tester.pump(const Duration(milliseconds: 50)); + } + await tester.tap(find.byType(DropdownButton)); + await tester.pumpAndSettle(); + await tester.tap(find.text('Sanitized A (#10)').last); + await tester.pumpAndSettle(); + expect(find.textContaining('identity is unconfirmed'), findsOneWidget); + expect(find.textContaining('#10'), findsWidgets); + await tester.runAsync( + () => tester.tap(find.text('Use this server note')), + ); + final confirmationDeadline = Stopwatch()..start(); + while (repository.noteById('uncertain-create')!.serverId == null && + confirmationDeadline.elapsed < const Duration(seconds: 30)) { + // Modal dismissal needs widget frames; SQLite needs real async time. + await tester.pump(const Duration(milliseconds: 50)); + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 20)), + ); + } + await tester.pump(const Duration(milliseconds: 250)); + final uiContainer = ProviderScope.containerOf( + tester.element(find.byType(NextcloudNoteStatus)), + ); + expect( + repository.noteById('uncertain-create')!.serverId, + 10, + reason: + '${uiContainer.read(workspaceControllerProvider).message?.error}', + ); + expect( + repository.noteById('uncertain-create')!.content, + 'newer editor content', + ); + expect( + repository.noteById('uncertain-create')!.hasPendingChanges, + isTrue, + ); + expect(repository.noteById('creation-candidate-10'), isNull); + expect(repository.noteById('creation-candidate-11'), isNotNull); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox.shrink()); + }, + ); + for (final state in [NoteSyncState.forbidden, NoteSyncState.unavailable]) { testWidgets( '${state.name} pending changes offer recovery without overwrite', @@ -793,7 +1032,26 @@ class _TabSettings implements LocalSettingsStore { class _CachedNotesRepository extends NotesRepository { _CachedNotesRepository({required super.store}) - : super(clientForAccount: (_) async => throw StateError('Cached UI only')); + : super( + clientForAccount: (account) async => NotesApiClient( + client: MockClient((request) async { + final id = int.parse(request.url.pathSegments.last); + final candidate = (await store.notes()).firstWhere( + (n) => n.serverId == id, + ); + return http.Response(jsonEncode(candidate.base!.toJson()), 200); + }), + account: account, + appPassword: 'fixture', + ), + ); + + int deleteCalls = 0; + @override + Future delete(String localId) { + deleteCalls++; + return super.delete(localId); + } @override Future synchronize(String accountId) async {} diff --git a/test/src/nextcloud_workspace_test.dart b/test/src/nextcloud_workspace_test.dart index faac4779..3e355c85 100644 --- a/test/src/nextcloud_workspace_test.dart +++ b/test/src/nextcloud_workspace_test.dart @@ -27,6 +27,139 @@ import 'nextcloud_notes/notes_api_test.dart' show serverNote; const _accountId = 'ba6d02cd-42fe-4e14-a577-3e2c13822459'; void main() { + test( + 'unsaved draft edits during confirmation require another review', + () async { + Map? remote; + final started = Completer(); + final release = Completer(); + var confirming = false; + final harness = await _Harness.create( + seedLocal: false, + deferPersistence: true, + client: MockClient((request) async { + if (request.method == 'POST') { + remote = {...serverNote(1), ...jsonDecode(request.body) as Map}; + throw http.ClientException('lost response'); + } + if (confirming && request.url.path.endsWith('/notes/1')) { + started.complete(); + await release.future; + confirming = false; + } + return http.Response( + jsonEncode( + request.url.path.endsWith('/notes') + ? [if (remote != null) remote] + : remote, + ), + 200, + ); + }), + ); + addTearDown(harness.dispose); + final repository = harness.repository; + final draft = await repository.create(_accountId, content: 'submitted'); + await repository.synchronize(_accountId); + await repository.synchronize(_accountId); + await harness.controller.openNextcloudWorkspace(_accountId); + await harness.controller.openNextcloudNote(draft.localId); + await harness.container + .read(appSettingsControllerProvider.notifier) + .setAutoSave(false); + final reviewed = repository.creationReview( + draft.localId, + repository.noteById(draft.localId)!.remote!, + ); + confirming = true; + final decision = harness.controller.resolveNextcloudConflict( + draft.localId, + NoteConflictResolution.useServerNote, + expectedRevision: reviewed.revision, + creationCandidateServerId: 1, + creationReview: reviewed, + ); + await started.future; + harness.controller.updateActiveText('unsaved newer draft edit'); + release.complete(); + await decision; + expect(repository.noteById(draft.localId)!.serverId, isNull); + expect(repository.noteById(draft.localId)!.creationAttempt, isNotNull); + expect(harness.state.activeBuffer!.text, 'unsaved newer draft edit'); + expect(harness.state.activeBuffer!.isDirty, isTrue); + expect(harness.state.message, isNotNull); + }, + ); + + test( + 'an unsaved independently opened candidate blocks consolidation without orphaning its tab', + () async { + Map? remote; + final harness = await _Harness.create( + seedLocal: false, + deferPersistence: true, + client: MockClient((request) async { + if (request.method == 'POST') { + remote = {...serverNote(1), ...jsonDecode(request.body) as Map}; + throw http.ClientException('lost response'); + } + return http.Response( + jsonEncode( + request.url.path.endsWith('/notes') + ? [if (remote != null) remote] + : remote, + ), + 200, + ); + }), + ); + addTearDown(harness.dispose); + final repository = harness.repository; + final draft = await repository.create( + _accountId, + content: 'wire content', + ); + await repository.synchronize(_accountId); + await repository.synchronize(_accountId); + final twin = repository.notes.firstWhere((n) => n.serverId == 1); + final reviewed = repository.creationReview( + draft.localId, + repository.noteById(draft.localId)!.remote!, + ); + expect( + await harness.controller.openNextcloudWorkspace(_accountId), + isTrue, + ); + expect(await harness.controller.openNextcloudNote(twin.localId), isTrue); + expect(harness.state.activeBuffer!.remoteNote!.localId, twin.localId); + await harness.container + .read(appSettingsControllerProvider.notifier) + .setAutoSave(false); + harness.controller.updateActiveText('unsaved candidate edit'); + expect(harness.state.activeBuffer!.text, 'unsaved candidate edit'); + await harness.controller.resolveNextcloudConflict( + draft.localId, + NoteConflictResolution.useServerNote, + expectedRevision: reviewed.revision, + creationCandidateServerId: 1, + creationReview: reviewed, + ); + expect(repository.noteById(draft.localId)!.serverId, isNull); + expect(repository.noteById(twin.localId), isNotNull); + final tab = harness.state.documentBuffers.firstWhere( + (b) => b.remoteNote?.localId == twin.localId, + ); + expect(tab.text, 'unsaved candidate edit'); + expect(tab.isDirty, isTrue); + expect(harness.state.message, isNotNull); + expect(await harness.controller.saveActive(), isTrue); + expect( + repository.noteById(twin.localId)!.content, + 'unsaved candidate edit', + ); + }, + ); + test( 'Save, Save All, autosave and shutdown durably retain offline notes', () async { diff --git a/test/src/source_editor_widget_test.dart b/test/src/source_editor_widget_test.dart index 1a4e3a27..1bece9ed 100644 --- a/test/src/source_editor_widget_test.dart +++ b/test/src/source_editor_widget_test.dart @@ -9365,6 +9365,7 @@ Future _pumpClipboardSourceEditor( width: 900, height: 600, child: BusyMarkSourceEditor( + key: UniqueKey(), text: source, language: SourceSyntaxLanguage.markdown, documentFormat: documentFormat, @@ -9397,8 +9398,10 @@ Future _pumpClipboardSourceEditor( ), ); final field = find.byType(TextField); - await tester.tap(field); + // Clipboard tests need keyboard focus; the centre can hit a selection + // toolbar instead. Each mount starts an independent editor session. await tester.showKeyboard(field); + expect(tester.widget(field).focusNode!.hasFocus, isTrue); return tester.widget(field).controller!; } diff --git a/test/src/source_paste_engine_test.dart b/test/src/source_paste_engine_test.dart index 27daf5a5..ebc65dde 100644 --- a/test/src/source_paste_engine_test.dart +++ b/test/src/source_paste_engine_test.dart @@ -13,6 +13,85 @@ import 'package:flutter_test/flutter_test.dart'; void main() { const engine = SourcePasteEngine(); + test( + 'HTML Source paste retains boundary spaces and formatting across links', + () { + for (final mode in [ + MarkdownMode.commonMark, + MarkdownMode.writersideMarkdown, + ]) { + for (final value in [ + ( + html: 'hello world ', + expected: '**hello world** \n', + linked: false, + ), + ( + html: 'hello world ', + expected: '**hello world** \n', + linked: false, + ), + ( + html: 'hello world ', + expected: '**hello [world](guide.md)** \n', + linked: true, + ), + ]) { + final fragment = const WysiwygClipboardHtml().decode( + value.html, + mode: mode, + )!; + final applied = _applyReady( + 'Target\n', + engine.prepareStructured( + target: SourcePasteDocumentSnapshot( + expectedSource: 'Target\n', + selection: const TextSelection(baseOffset: 0, extentOffset: 6), + format: SourceDocumentFormat.markdown, + markdownMode: mode, + filePath: '/project/source.md', + ), + fragment: fragment, + ), + ); + expect(applied.source, value.expected); + final block = const MarkdownParser() + .parse( + filePath: '/project/source.md', + source: applied.source, + mode: mode, + ) + .busyDocument + .blocks + .single; + expect(block.plainText, 'hello world'); + final ranges = busyInlineStyleRanges(block.inlines); + expect( + { + for (final range in ranges.where( + (range) => range.kind == BusyInlineKind.strong, + )) + for (var offset = range.start; offset < range.end; offset++) + offset, + }, + {for (var offset = 0; offset < 11; offset++) offset}, + ); + final links = ranges.where( + (range) => range.kind == BusyInlineKind.link, + ); + if (value.linked) { + expect( + (links.single.start, links.single.end, links.single.destination), + (6, 11, 'guide.md'), + ); + } else { + expect(links, isEmpty); + } + } + } + }, + ); + test('single rich paragraph repairs independently surviving endpoints', () { final cases = <({String source, int start, int end, String expected})>[ ( diff --git a/test/src/spellcheck_projection_test.dart b/test/src/spellcheck_projection_test.dart index cae539d0..ffb27e52 100644 --- a/test/src/spellcheck_projection_test.dart +++ b/test/src/spellcheck_projection_test.dart @@ -281,7 +281,9 @@ void main() { expect(texts, contains('28. Document Conclusion')); } expect( - rich.runs.firstWhere((run) => run.text == 'Raw HTML block ').target, + rich.runs + .firstWhere((run) => run.text.trim() == 'Raw HTML block') + .target, isA(), reason: 'Protected HTML still gets checked in Source.', ); diff --git a/test/src/spelling_line_breaks_test.dart b/test/src/spelling_line_breaks_test.dart new file mode 100644 index 00000000..fb9e64e4 --- /dev/null +++ b/test/src/spelling_line_breaks_test.dart @@ -0,0 +1,245 @@ +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/markdown_model.dart'; +import 'package:busymark/src/markdown/markdown_parser.dart'; +import 'package:busymark/src/spellcheck/markdown_spelling_projection.dart'; +import 'package:busymark/src/spellcheck/spelling_projection.dart'; +import 'package:busymark/src/spellcheck/spelling_replacement.dart'; +import 'package:busymark/src/spellcheck/wysiwyg_spelling_projection.dart'; +import 'package:busymark/src/workspace/workspace_model.dart'; +import 'package:flutter_test/flutter_test.dart'; + +const _snapshot = SpellingSnapshotIdentity( + bufferId: 'line-breaks', + contentRevision: 1, + documentKind: DocumentKind.markdown, + contextGeneration: 1, +); + +void _expectProseAndCorrection( + String source, + String expectedText, + MarkdownMode mode, +) { + const filePath = '/line-breaks.md'; + final document = const MarkdownParser() + .parse( + filePath: filePath, + source: source, + mode: mode, + validateLocalReferences: false, + ) + .busyDocument; + final plain = const MarkdownSpellingProjector().project( + filePath: filePath, + source: source, + mode: mode, + languageId: 'en-CA', + snapshot: _snapshot, + ); + final rich = const WysiwygSpellingProjector().project( + document: document, + languageId: 'en-CA', + snapshot: _snapshot, + documentGeneration: 1, + ); + for (final projection in [plain, rich]) { + expect( + projection.complete, + isTrue, + reason: '$source: ${projection.message}', + ); + expect(projection.runs.map((run) => run.text), [ + expectedText, + ], reason: source); + final run = projection.runs.single; + expect(run.hasValidMapping, isTrue); + for (final word in ['helo', 'wrld']) { + final offset = run.text.indexOf(word); + final occurrence = SpellingOccurrence( + id: word, + run: run, + logicalStart: offset, + logicalEnd: offset + word.length, + word: word, + outcome: SpellingCheckOutcome.rejected, + ); + expect(occurrence.sourceStart, source.indexOf(word)); + expect(occurrence.sourceEnd, source.indexOf(word) + word.length); + final replacement = word == 'helo' ? 'hello' : 'world'; + final plan = const SpellingReplacementPlanner().build( + occurrence: occurrence, + suggestion: replacement, + ); + final expectedSource = source.replaceFirst(word, replacement); + expect(plan.applyToSource(source), expectedSource); + if (identical(projection, rich)) { + expect(run.target, isA()); + final target = run.target as SpellingRichBlockTarget; + final controller = BusyMarkWysiwygDocumentController( + document: document, + ); + try { + expect( + controller.replaceSpellingInBlock( + blockId: target.blockId, + expectedFieldText: busyMarkWysiwygEditableText( + controller.blockById(target.blockId)!, + ), + plan: plan, + ), + isTrue, + ); + expect(controller.markdown, expectedSource); + } finally { + controller.dispose(); + } + } + } + } +} + +void main() { + for (final mode in [ + MarkdownMode.commonMark, + MarkdownMode.gfm, + MarkdownMode.writersideMarkdown, + ]) { + group(mode.name, () { + for (final (name, prefix, continuation) in [ + ('paragraph', '', ''), + ('numbered list', '1. ', ' '), + ('lazy list continuation', '1. ', ''), + ('bulleted list', '- ', ' '), + ('blockquote', '> ', '> '), + ('list in a blockquote', '> 1. ', '> '), + ]) { + for (final (breakName, suffix, expected) in [ + ('soft break', '', 'helo wrld'), + ('space before soft break', ' ', 'helo wrld'), + ('two-space hard break', ' ', 'helo wrld'), + ('three-space hard break', ' ', 'helo wrld'), + ('backslash hard break', '\\', 'helo wrld'), + ('escaped backslash', '\\\\', 'helo\\ wrld'), + ('escaped backslash before hard break', '\\\\\\', 'helo\\ wrld'), + ('tab before soft break', '\t', 'helo\t wrld'), + ('space and tab before soft break', ' \t', 'helo \t wrld'), + ]) { + test('$name preserves $breakName and correction positions', () { + for (final lineEnding in ['\n', '\r\n', '\r']) { + _expectProseAndCorrection( + '$prefix' + 'helo$suffix$lineEnding$continuation**wrld**$lineEnding', + expected, + mode, + ); + } + }); + } + } + + test('literal numbered continuation remains prose', () { + for (final marker in ['2.', '2)']) { + _expectProseAndCorrection( + 'helo\n$marker wrld\n', + 'helo $marker wrld', + mode, + ); + } + }); + + test('indented paragraph continuation follows parser boundaries', () { + _expectProseAndCorrection('helo\n wrld\n', 'helo wrld', mode); + }); + + test('inline HTML breaks retain surrounding prose and corrections', () { + for (final tag in ['
', '
', '
', '
']) { + _expectProseAndCorrection('helo $tag **wrld**\n', 'helo wrld', mode); + _expectProseAndCorrection('helo $tag wrld\n', 'helo wrld', mode); + } + }); + + test('HTML layout newlines do not add extra spelling breaks', () { + for (final (source, expected) in [ + ('helo
\nwrld\n', 'helo wrld'), + ('helo\n
\n**wrld**\n', 'helo wrld'), + ('- helo
\n wrld\n', 'helo wrld'), + ('> helo
\n> wrld\n', 'helo wrld'), + ('helo
wrld\n', 'helo wrld'), + ('a helo
wrld
\n', 'a helo wrld'), + ('helo wrld\n', 'helo wrld'), + ('[helo
wrld](target)\n', 'helo wrld'), + ('[helo
\nwrld](target)\n', 'helo wrld'), + ('[helo\n
\n**wrld**](target)\n', 'helo wrld'), + ('helo

wrld\n', 'helo wrld'), + ('helo &
wrld\n', 'helo & wrld'), + ('helo
wrld text\n', 'helo wrld text'), + ]) { + _expectProseAndCorrection(source, expected, mode); + } + }); + + test('HTML breaks keep readable metadata as a separate spelling run', () { + for (final lineEnding in [' ', '\n', '\r\n']) { + for (final linked in [false, true]) { + final prose = 'helowrld'; + final source = linked ? '[$prose](target)\n' : '$prose\n'; + final document = const MarkdownParser() + .parse( + filePath: '/break-metadata.md', + source: source, + mode: mode, + validateLocalReferences: false, + ) + .busyDocument; + final rich = const WysiwygSpellingProjector().project( + document: document, + languageId: 'en-CA', + snapshot: _snapshot, + documentGeneration: 1, + ); + expect(rich.complete, isTrue, reason: source); + expect(rich.runs.map((run) => run.text), [ + 'helo wrld', + 'titel', + ], reason: source); + final metadata = rich.runs.last; + expect(metadata.target, isA()); + final occurrence = SpellingOccurrence( + id: 'title', + run: metadata, + logicalStart: 0, + logicalEnd: 5, + word: 'titel', + outcome: SpellingCheckOutcome.rejected, + ); + final plan = const SpellingReplacementPlanner().build( + occurrence: occurrence, + suggestion: 'title', + ); + expect( + plan.applyToSource(source), + source.replaceFirst('titel', 'title'), + ); + } + } + }); + + test('formatting can contain a container line break', () { + for (final (prefix, continuation) in [('-', ' '), ('>', '> ')]) { + _expectProseAndCorrection( + '$prefix **helo \n$continuation' + 'wrld**\n', + 'helo wrld', + mode, + ); + } + }); + + test('HTML displayed as literal text keeps exact spelling targets', () { + const literal = 'helo wrld'; + _expectProseAndCorrection('$literal\n', literal, mode); + }); + }); + } +} diff --git a/test/src/spelling_service_test.dart b/test/src/spelling_service_test.dart index c62577f9..59b337b0 100644 --- a/test/src/spelling_service_test.dart +++ b/test/src/spelling_service_test.dart @@ -4865,10 +4865,11 @@ void main() { applicationSupportRoot: p.join(temporary.path, 'support'), dictionaryStorageRoot: p.join(temporary.path, 'dictionary-storage'), wordStoreReader: (store) async { + final snapshot = await store.read(); if (!store.projectStore && ++personalReads == 3) { secondPersonalRefresh.complete(); } - return store.read(); + return snapshot; }, catalogLoader: ({ @@ -4897,7 +4898,9 @@ void main() { await entered.future; final second = controller.prepareSettings(null); await secondPersonalRefresh.future; - await Future.delayed(Duration.zero); + // The read has completed; drain its continuations so the second prepare + // joins the catalog request before releasing that request. + await Future(() {}); expect(loads, 2); release.complete(); await Future.wait([first, second]); diff --git a/test/src/spelling_workspace_test.dart b/test/src/spelling_workspace_test.dart index 5fc4c886..6246d316 100644 --- a/test/src/spelling_workspace_test.dart +++ b/test/src/spelling_workspace_test.dart @@ -37,6 +37,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:path/path.dart' as p; import '../support/spelling_test_bundle.dart'; +import '../support/widget_async.dart'; void main() { final l10n = AppLocalizationsEn(); @@ -78,6 +79,47 @@ void main() { } }); + testWidgets( + 'Writerside list hard break supports underlines and spelling review', + (tester) async { + const source = '1. helo \n**world**\n\n2. hello\n'; + const corrected = '1. hello \n**world**\n\n2. hello\n'; + final harness = await _pumpWorkspace( + tester, + source: source, + writerside: true, + ); + await _until(tester, () => harness.spelling.state.complete); + expect(find.text(l10n.spellingCheckIncomplete), findsNothing); + expect(harness.spelling.misspellings.map((word) => word.word), ['helo']); + final target = + harness.spelling.misspellings.single.run.target + as SpellingRichBlockTarget; + final field = tester + .widgetList( + find.byType(BusyMarkWysiwygBlockField), + ) + .singleWhere((field) => field.block.id == target.blockId); + expect(field.spellingRanges, isNotEmpty); + await tester.sendKeyEvent(LogicalKeyboardKey.f7); + final dialog = find.byType(BusyMarkSpellingReviewDialog); + final suggestion = find.descendant( + of: dialog, + matching: find.text('hello'), + ); + await _until(tester, () => suggestion.evaluate().isNotEmpty); + await tester.tap(suggestion); + await _until( + tester, + () => dialog.evaluate().isEmpty && harness.spelling.state.complete, + ); + expect(harness.workspace.activeText, corrected); + expect(harness.spelling.misspellings, isEmpty); + expect(find.text(l10n.spellingCheckIncomplete), findsNothing); + await tester.pumpWidget(const SizedBox.shrink()); + }, + ); + testWidgets( 'Writerside nested TOC and comment retain rich spelling through correction', (tester) async { @@ -1462,6 +1504,7 @@ void main() { harness.workspace.activeBuffer?.editorState.spellingLanguage, const SpellingLanguageOverride.inherit(), ); + expect(harness.spelling.catalog?.installedById('fr-Test'), isNull); releaseDownload.complete(); await _until( @@ -1469,10 +1512,17 @@ void main() { () => harness.workspace.activeBuffer?.editorState.spellingLanguage == const SpellingLanguageOverride.selected('fr-Test'), + diagnostics: () => + 'downloads=$downloadCount, ' + 'installation=${harness.spelling.dictionaryInstallStatus}, ' + 'override=${harness.workspace.activeBuffer?.editorState.spellingLanguage}', ); + expect(downloadCount, 2); expect(harness.spelling.catalog?.installedById('fr-Test'), isNotNull); + expect(harness.spelling.dictionaryInstallStatus, isNull); await tester.pump(const Duration(milliseconds: 300)); await _until(tester, () => harness.spelling.state.complete); + expect(harness.spelling.effectiveLanguage, 'fr-Test'); }); testWidgets('failed document dictionary install preserves override', ( @@ -1671,19 +1721,14 @@ Future _until( Duration timeout = const Duration(seconds: 10), String Function()? diagnostics, }) async { - final elapsed = Stopwatch()..start(); - while (!condition()) { - if (elapsed.elapsed > timeout) { - throw TimeoutException( - 'Spelling workspace did not settle. ${diagnostics?.call() ?? ''}', - ); - } - await tester.pump(const Duration(milliseconds: 50)); - await tester.runAsync( - () => Future.delayed(const Duration(milliseconds: 10)), - ); - } - await tester.pump(); + await pumpUntil( + tester, + condition, + operation: 'Spelling workspace', + timeout: timeout, + frameStep: const Duration(milliseconds: 50), + diagnostics: diagnostics, + ); } class _Settings implements LocalSettingsStore { diff --git a/test/src/visualization_packaging_audit_test.dart b/test/src/visualization_packaging_audit_test.dart index 941f1ee2..5643f67a 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\.0$', multiLine: true))); + expect(pubspec, contains(RegExp(r'^version: 0\.6\.1$', multiLine: true))); expect( snapcraft, - contains(RegExp(r'^version: "0\.6\.0"$', multiLine: true)), + contains(RegExp(r'^version: "0\.6\.1"$', multiLine: true)), ); expect(snapcraft, contains(RegExp(r'^grade: stable$', multiLine: true))); expect( @@ -57,9 +57,9 @@ void main() { ), ), ); - expect(metainfo, contains(' root.delete(recursive: true)); + await File( + p.join(root.path, 'saved.md'), + ).writeAsString('# Saved\n'); + } + final harness = await _createControllerHarness( + fileMonitor: _ControlledFileMonitor(), + ); + await harness.settingsController.setAutoSave(false); + final controller = harness.controller._notifier; + await controller.openPath(root.path); + final savedId = controller.state.activeBuffer!.id; + await controller.createMarkdownFile(); + final draft = controller.state.activeBuffer!; + await controller.closeDocumentBuffer(savedId); + expect(controller.state.documentBuffers.single.id, draft.id); + final workspaceId = controller.state.workspace!.id; + if (discard) { + expect(await controller.discardActiveChanges(), isTrue); + } else { + controller.state = controller.state.copyWith( + documentBuffers: [draft.copyWith(dirty: false)], + ); + expect(await controller.closeAllOpenFileTabs(), isTrue); + } + expect(controller.state.documentBuffers, isEmpty); + expect(controller.state.workspace!.id, workspaceId); + expect( + controller.state.workspace!.kind, + project + ? WorkspaceKind.writersideModule + : WorkspaceKind.markdownFolder, + ); + expect(controller.state.workspace!.openFilePaths, isEmpty); + expect(controller.state.workspace!.sourceOverrides, isEmpty); + }, + ); + } + } + test( 'closing all Writerside tabs completes override relinquishment', () async { diff --git a/test/src/workspace_safety_test.dart b/test/src/workspace_safety_test.dart index 3f8e8a95..6e9819b2 100644 --- a/test/src/workspace_safety_test.dart +++ b/test/src/workspace_safety_test.dart @@ -10,6 +10,10 @@ 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:busymark/src/workspace/workspace_safety.dart'; +import 'package:busymark/src/workspace/workspace_tab_actions.dart'; +import 'package:busymark/src/workspace/workspace_tabs.dart'; +import 'package:busymark/src/git/application/git_controller.dart'; +import 'package:busymark/src/local_history/local_history_controller.dart'; import 'package:busymark/src/workspace/workspace_service.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -1018,6 +1022,513 @@ void main() { }, ); } + Future< + ({WidgetRef ref, WorkspaceController controller, bool? Function() result}) + > + closeHarness( + WidgetTester tester, + _IdentityWorkspaceService service, + Set targets, { + String operation = 'confirm', + WorkspaceController Function()? createController, + }) async { + bool? result; + late WidgetRef ref; + await tester.pumpWidget( + ProviderScope( + overrides: [ + localSettingsStoreProvider.overrideWithValue( + _MemorySettingsStore() + ..value = AppSettings.defaults() + .copyWith(autoSave: false, validateOnEdit: false) + .toJson(), + ), + workspaceServiceProvider.overrideWithValue(service), + if (createController != null) + workspaceControllerProvider.overrideWith(createController), + ], + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: Consumer( + builder: (context, widgetRef, _) { + ref = widgetRef; + return TextButton( + onPressed: () async { + final state = ref.read(workspaceControllerProvider); + final workspace = state.workspace!; + final tab = WorkspaceTabEntry.file( + path: '', + active: false, + bufferId: targets.firstOrNull ?? state.activeBufferId!, + ); + result = switch (operation) { + 'one' => await closeWorkspaceTab( + context, + ref, + workspace: workspace, + tab: tab, + ), + 'other' => await closeOtherWorkspaceTabs( + context, + ref, + workspace: workspace, + retainedTab: tab, + ), + 'all' => await closeAllWorkspaceTabs( + context, + ref, + workspace: workspace, + ), + _ => await confirmSafeToCloseDocumentBuffers( + context, + ref, + targets, + ), + }; + }, + child: const Text('Close targets'), + ); + }, + ), + ), + ), + ), + ); + final controller = ref.read(workspaceControllerProvider.notifier); + await controller.openPath(service.rootPath); + final firstId = ref.read(workspaceControllerProvider).activeBufferId!; + await controller.openActiveFile(service.secondPath); + await controller.activateDocumentBuffer(firstId); + return (ref: ref, controller: controller, result: () => result); + } + + for (final choice in ['Cancel', 'Save', 'Discard']) { + testWidgets( + 'ID scoped confirmation combines dirty targets and excludes retained draft on $choice', + (tester) async { + final service = _IdentityWorkspaceService(); + final targets = {}; + final h = await closeHarness(tester, service, targets); + h.controller.updateActiveText('# Edited first\n'); + targets.add(h.ref.read(workspaceControllerProvider).activeBufferId!); + await h.controller.openActiveFile(service.secondPath); + h.controller.updateActiveText('# Edited second\n'); + targets.add(h.ref.read(workspaceControllerProvider).activeBufferId!); + await h.controller.createMarkdownFile(); + h.controller.updateActiveText('# Retained dirty\n'); + final retained = h.ref.read(workspaceControllerProvider).activeBuffer!; + await tester.tap(find.text('Close targets')); + await tester.pumpAndSettle(); + expect( + find.text(l10n.unsavedChangesMultipleMessage(2)), + findsOneWidget, + ); + expect(find.text(retained.displayName), findsNothing); + await tester.tap(find.text(choice)); + await tester.pumpAndSettle(); + expect(h.result(), choice != 'Cancel'); + final state = h.ref.read(workspaceControllerProvider); + expect(state.activeBufferId, retained.id); + expect(state.activeBuffer!.text, retained.text); + expect(state.activeBuffer!.isDirty, isTrue); + expect(state.dirtyBuffers, hasLength(choice == 'Cancel' ? 3 : 1)); + expect(service.saves, hasLength(choice == 'Save' ? 2 : 0)); + }, + ); + } + + for (final change in ['revision', 'active', 'workspace', 'removed']) { + testWidgets( + 'ID scoped confirmation rejects $change changes while dialog is open', + (tester) async { + final service = _IdentityWorkspaceService(); + final targets = {}; + final h = await closeHarness(tester, service, targets); + h.controller.updateActiveText('# Dirty original\n'); + targets.add(h.ref.read(workspaceControllerProvider).activeBufferId!); + await tester.tap(find.text('Close targets')); + await tester.pumpAndSettle(); + if (change == 'revision') { + h.controller.updateActiveText('# New revision\n'); + } + if (change == 'active') { + await h.controller.openActiveFile(service.secondPath); + } + if (change == 'workspace') { + await h.controller.openPath(service.saveAsPath); + } + if (change == 'removed') { + await h.controller.closeDocumentBuffer(targets.first, discard: true); + } + final before = h.ref.read(workspaceControllerProvider); + await tester.tap(find.text(l10n.discard)); + await tester.pumpAndSettle(); + expect(h.result(), isFalse); + final after = h.ref.read(workspaceControllerProvider); + expect( + after.documentBuffers.map((b) => b.id), + before.documentBuffers.map((b) => b.id), + ); + expect(after.activeBufferId, before.activeBufferId); + expect(after.activeText, before.activeText); + expect(service.saves, isEmpty); + }, + ); + } + + testWidgets( + 'failed scoped save stops subsequent targets and ordinary closing', + (tester) async { + final service = _IdentityWorkspaceService()..failSave = true; + final targets = {}; + final h = await closeHarness(tester, service, targets, operation: 'all'); + h.controller.updateActiveText('# First edited\n'); + await h.controller.openActiveFile(service.secondPath); + h.controller.updateActiveText('# Second edited\n'); + final before = h.ref.read(workspaceControllerProvider); + await tester.tap(find.text('Close targets')); + await tester.pumpAndSettle(); + await tester.tap(find.text(l10n.save)); + await tester.pumpAndSettle(); + expect(h.result(), isFalse); + expect(service.saves, hasLength(1)); + final after = h.ref.read(workspaceControllerProvider); + expect( + after.documentBuffers.map((b) => b.id), + before.documentBuffers.map((b) => b.id), + ); + expect(after.dirtyBuffers, hasLength(2)); + expect(after.activeBufferId, before.activeBufferId); + }, + ); + + for (final saveAs in ['cancel', 'save']) { + testWidgets('scoped tab close supports untitled first Save As $saveAs', ( + tester, + ) async { + final service = _IdentityWorkspaceService(); + const channel = MethodChannel('plugins.flutter.io/file_selector'); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, ( + call, + ) async { + expect(call.method, 'getSavePath'); + return saveAs == 'save' ? service.saveAsPath : null; + }); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + channel, + null, + ), + ); + final targets = {}; + final h = await closeHarness(tester, service, targets, operation: 'one'); + await h.controller.createMarkdownWorkspace(); + h.controller.updateActiveText('# Untitled close draft\n'); + targets.add(h.ref.read(workspaceControllerProvider).activeBufferId!); + await tester.tap(find.text('Close targets')); + await tester.pumpAndSettle(); + await tester.tap(find.text(l10n.save)); + await tester.pumpAndSettle(); + expect(h.result(), saveAs == 'save'); + final state = h.ref.read(workspaceControllerProvider); + if (saveAs == 'save') { + expect(state.documentBuffers, isEmpty); + expect(state.workspace!.kind, WorkspaceKind.singleMarkdown); + expect( + service.documents[service.saveAsPath], + '# Untitled close draft\n', + ); + } else { + expect(state.activeBufferId, targets.first); + expect(state.activeText, '# Untitled close draft\n'); + expect(state.isDirty, isTrue); + } + }); + } + + testWidgets( + 'close all aborts when another document opens during confirmation', + (tester) async { + final service = _IdentityWorkspaceService(); + final h = await closeHarness( + tester, + service, + {}, + operation: 'all', + ); + h.controller.updateActiveText('# Edited first\n'); + await tester.tap(find.text('Close targets')); + await tester.pumpAndSettle(); + final activeId = h.ref.read(workspaceControllerProvider).activeBufferId!; + await h.controller.createMarkdownFile(); + final newId = h.ref.read(workspaceControllerProvider).activeBufferId!; + await h.controller.activateDocumentBuffer(activeId); + await tester.tap(find.text(l10n.discard)); + await tester.pumpAndSettle(); + expect(h.result(), isFalse); + expect( + h.ref + .read(workspaceControllerProvider) + .documentBuffers + .any((b) => b.id == newId), + isTrue, + ); + expect( + h.ref.read(workspaceControllerProvider).documentBuffers, + hasLength(3), + ); + }, + ); + + for (final operation in ['one', 'other', 'all']) { + testWidgets( + 'rejected $operation close stops and preserves comparison state', + (tester) async { + final targets = {}; + final controller = _RejectedCloseController(); + final h = await closeHarness( + tester, + _IdentityWorkspaceService(), + targets, + operation: operation, + createController: () => controller, + ); + if (operation == 'other') { + await h.controller.createMarkdownFile(); + } + final before = h.ref.read(workspaceControllerProvider); + targets.add(before.documentBuffers.last.id); + final git = h.ref.read(gitControllerProvider.notifier); + git.state = git.state.copyWith(openDiffFilePaths: ['comparison.md']); + final history = h.ref.read(localHistoryControllerProvider.notifier); + history.state = history.state.copyWith( + selectedRevisionId: 'revision-to-keep', + ); + await tester.tap(find.text('Close targets')); + await tester.pumpAndSettle(); + expect(h.result(), isFalse); + expect(controller.closedIds, hasLength(operation == 'all' ? 0 : 1)); + expect(controller.bulkCalls, operation == 'all' ? 1 : 0); + expect( + h.ref + .read(workspaceControllerProvider) + .documentBuffers + .map((b) => b.id), + before.documentBuffers.map((b) => b.id), + ); + expect(h.ref.read(gitControllerProvider).openDiffFilePaths, [ + 'comparison.md', + ]); + expect( + h.ref.read(localHistoryControllerProvider).selectedRevisionId, + 'revision-to-keep', + ); + }, + ); + } + + testWidgets('canceled Save As after an earlier save stops close other tabs', ( + tester, + ) async { + final service = _IdentityWorkspaceService(); + final targets = {}; + final h = await closeHarness(tester, service, targets, operation: 'other'); + h.controller.updateActiveText('# Retained dirty text\n'); + final retained = h.ref.read(workspaceControllerProvider).activeBuffer!; + targets.add(retained.id); + await h.controller.openActiveFile(service.secondPath); + h.controller.updateActiveText('# Target to save\n'); + await h.controller.createMarkdownFile(); + h.controller.updateActiveText('# Untitled target\n'); + const channel = MethodChannel('plugins.flutter.io/file_selector'); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + channel, + (_) async => null, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + channel, + null, + ), + ); + await tester.tap(find.text('Close targets')); + await tester.pumpAndSettle(); + expect(find.text(l10n.unsavedChangesMultipleMessage(2)), findsOneWidget); + await tester.tap(find.text(l10n.save)); + await tester.pumpAndSettle(); + expect(h.result(), isFalse); + final state = h.ref.read(workspaceControllerProvider); + expect(state.documentBuffers, hasLength(3)); + expect( + state.documentBuffers.singleWhere((b) => b.id == retained.id).text, + retained.text, + ); + expect( + state.documentBuffers.singleWhere((b) => b.id == retained.id).isDirty, + isTrue, + ); + expect(state.bufferForPath(service.secondPath)!.isDirty, isFalse); + expect(service.saves.single, ( + path: service.secondPath, + text: '# Target to save\n', + )); + }); + + for (final standalone in [false, true]) { + testWidgets( + 'close all finishes when discard already removes ${standalone ? 'standalone' : 'folder'} untitled targets', + (tester) async { + final h = await closeHarness( + tester, + _IdentityWorkspaceService(), + {}, + operation: 'all', + ); + if (standalone) { + await h.controller.createMarkdownWorkspace(); + } else { + final savedIds = h.ref + .read(workspaceControllerProvider) + .documentBuffers + .map((b) => b.id) + .toList(); + await h.controller.createMarkdownFile(); + for (final id in savedIds) { + await h.controller.closeDocumentBuffer(id); + } + } + h.controller.updateActiveText('# Draft to discard\n'); + await h.controller.createMarkdownFile(); + await tester.tap(find.text('Close targets')); + await tester.pumpAndSettle(); + expect( + find.text(l10n.unsavedChangesMultipleMessage(2)), + findsOneWidget, + ); + await tester.tap(find.text(l10n.discard)); + await tester.pumpAndSettle(); + expect(h.result(), isTrue); + final state = h.ref.read(workspaceControllerProvider); + expect(state.documentBuffers, isEmpty); + if (standalone) { + expect(state.workspace, isNull); + } else { + expect(state.workspace!.kind, WorkspaceKind.markdownFolder); + } + }, + ); + } + + testWidgets( + 'close other tabs keeps documents opened during confirmation outside the captured set', + (tester) async { + final service = _IdentityWorkspaceService(); + final targets = {}; + final h = await closeHarness( + tester, + service, + targets, + operation: 'other', + ); + targets.add(h.ref.read(workspaceControllerProvider).activeBufferId!); + await h.controller.openActiveFile(service.secondPath); + h.controller.updateActiveText('# Target dirty\n'); + await tester.tap(find.text('Close targets')); + await tester.pumpAndSettle(); + final originalActiveId = h.ref + .read(workspaceControllerProvider) + .activeBufferId!; + await h.controller.createMarkdownFile(); + final newBuffer = h.ref.read(workspaceControllerProvider).activeBuffer!; + await h.controller.activateDocumentBuffer(originalActiveId); + await tester.tap(find.text(l10n.discard)); + await tester.pumpAndSettle(); + expect(h.result(), isTrue); + final state = h.ref.read(workspaceControllerProvider); + expect(state.documentBuffers.map((b) => b.id), [ + targets.first, + newBuffer.id, + ]); + expect(state.activeBufferId, targets.first); + expect(state.documentBuffers.last.isDirty, isTrue); + }, + ); + + testWidgets( + 'close other tabs preserves a comparison opened during confirmation', + (tester) async { + final service = _IdentityWorkspaceService(); + final targets = {}; + final h = await closeHarness( + tester, + service, + targets, + operation: 'other', + ); + targets.add(h.ref.read(workspaceControllerProvider).activeBufferId!); + await h.controller.openActiveFile(service.secondPath); + h.controller.updateActiveText('# Target dirty\n'); + await tester.tap(find.text('Close targets')); + await tester.pumpAndSettle(); + final history = h.ref.read(localHistoryControllerProvider.notifier); + await history.flushAll( + h.ref.read(workspaceControllerProvider).documentBuffers, + ); + await history.selectDocumentForBuffer( + h.ref.read(workspaceControllerProvider).activeBuffer!, + ); + final revision = h.ref + .read(localHistoryControllerProvider) + .selectedRevisions + .first + .id; + await history.selectRevision(revision); + await tester.tap(find.text(l10n.discard)); + await tester.pumpAndSettle(); + expect(h.result(), isFalse); + expect( + h.ref.read(workspaceControllerProvider).documentBuffers, + hasLength(2), + ); + expect( + h.ref.read(localHistoryControllerProvider).selectedRevisionId, + revision, + ); + }, + ); + + testWidgets( + 'shared closing rejects overlapping operations before a second prompt', + (tester) async { + final targets = {}; + final h = await closeHarness( + tester, + _IdentityWorkspaceService(), + targets, + operation: 'all', + ); + h.controller.updateActiveText('# Dirty\n'); + await tester.tap(find.text('Close targets')); + await tester.pumpAndSettle(); + final state = h.ref.read(workspaceControllerProvider); + final second = await closeAllWorkspaceTabs( + h.ref.context, + h.ref, + workspace: state.workspace!, + ); + expect(second, isFalse); + expect(find.text(l10n.unsavedChanges), findsOneWidget); + await tester.tap(find.text(l10n.cancel)); + await tester.pumpAndSettle(); + expect(h.result(), isFalse); + expect( + h.ref.read(workspaceControllerProvider).documentBuffers, + hasLength(2), + ); + }, + ); } double _contrastRatio(Color foreground, Color background) { @@ -1057,6 +1568,7 @@ class _IdentityWorkspaceService extends WorkspaceService { final firstSaveStarted = Completer(); final pathExistsCheckStarted = Completer(); final pathExistsChecks = []; + var failSave = false; var firstChangedOnDisk = false; var pauseFirstSave = false; Completer? pendingFileChangedResult; @@ -1130,6 +1642,7 @@ class _IdentityWorkspaceService extends WorkspaceService { @override Future saveText(String path, String text) async { saves.add((path: path, text: text)); + if (failSave) throw StateError('save rejected'); if (pauseFirstSave && saves.length == 1) { firstSaveStarted.complete(); await _releaseFirstSave.future; @@ -1151,6 +1664,17 @@ class _IdentityWorkspaceService extends WorkspaceService { } } + @override + Future saveNewText( + String path, + String text, { + Future Function()? onPublished, + }) async { + final result = await saveText(path, text); + await onPublished?.call(); + return result; + } + @override Future saveTextReplacingPath( String path, @@ -1197,3 +1721,22 @@ class _IdentityWorkspaceService extends WorkspaceService { ); } } + +class _RejectedCloseController extends WorkspaceController { + final closedIds = []; + int bulkCalls = 0; + @override + Future closeDocumentBuffer( + String bufferId, { + bool discard = false, + }) async { + closedIds.add(bufferId); + return false; + } + + @override + Future closeAllOpenFileTabs() async { + bulkCalls++; + return false; + } +} diff --git a/test/src/writerside_toc_regressions_test.dart b/test/src/writerside_toc_regressions_test.dart index 72f60043..173082df 100644 --- a/test/src/writerside_toc_regressions_test.dart +++ b/test/src/writerside_toc_regressions_test.dart @@ -12,12 +12,14 @@ 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:busymark/src/writerside/writerside_template_service.dart'; +import 'package:busymark/src/writerside/writerside_topic_removal_service.dart'; import 'package:flutter/material.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:path/path.dart' as p; +import 'package:xml/xml.dart'; void main() { late Directory root; @@ -137,6 +139,51 @@ void main() { await tester.pumpAndSettle(); } + Future openRemoval(WidgetTester tester, {String row = '0'}) async { + await tester.tap( + find.byKey(ValueKey('workspace-sidebar-toc-row-$row')), + buttons: kSecondaryMouseButton, + ); + await tester.pumpAndSettle(); + await tester.runAsync(() => tester.tap(find.text('Remove TOC Element'))); + await _settle( + tester, + until: () => find.text('Set redirect to:').evaluate().isNotEmpty, + ); + } + + Finder homeSelector() => + find.byType(BusyMarkPopupSelector); + + BusyMarkDialogButton removeButton(WidgetTester tester) => + tester.widget(find.widgetWithText(BusyMarkDialogButton, 'Remove')); + + Finder automaticCheckbox() => find + .descendant( + of: find.byType(BusyMarkDialogShell), + matching: find.byType(BusyMarkCheckbox), + ) + .last; + + 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.pumpAndSettle(); + } + + Future keepOrphan(WidgetTester tester) async { + await _settle( + tester, + until: () => find.text('Keep Topic File').evaluate().isNotEmpty, + timeout: const Duration(seconds: 30), + diagnostics: () => + '${container.read(workspaceControllerProvider).message}', + ); + await tester.tap(find.text('Keep Topic File')); + await _settle(tester); + } + bool selected(WidgetTester tester, String path) => (tester.widget( find.descendant( @@ -271,12 +318,289 @@ void main() { findsOneWidget, ); expect(find.text('Set redirect to'), findsNothing); + expect( + find.text( + 'This topic is the Home Page for this instance. Choose another Home Page before removing it from the TOC.', + ), + findsOneWidget, + ); + expect(find.text('New Home Page'), findsOneWidget); + final picker = tester + .widget>( + homeSelector(), + ); + expect(picker.value, isNull); + expect(picker.options.map((option) => option.value.topicReference), [ + 'guides/install.md', + 'elsewhere/install.md', + ]); + expect(removeButton(tester).onPressed, isNull); + await chooseHome(tester); + expect(removeButton(tester).onPressed, isNotNull); await tester.tap(find.text('Cancel')); await tester.pumpAndSettle(); expect(await tester.runAsync(tree.readAsString), original); + expect(File(topicPath('other.md')).existsSync(), isTrue); + }, + ); + + testWidgets( + 'Home Page removal assigns the chosen topic and keeps the orphan source', + (tester) async { + await start(tester); + await openRemoval(tester); + await tester.tap(automaticCheckbox()); + await tester.pumpAndSettle(); + expect( + tester.widget(automaticCheckbox()).value, + isFalse, + ); + await chooseHome(tester); + expect(removeButton(tester).onPressed, isNotNull); + await tester.runAsync(() => tester.tap(find.text('Remove'))); + await keepOrphan(tester); + final tree = XmlDocument.parse( + File(p.join(root.path, 'guide.tree')).readAsStringSync(), + ); + expect(tree.rootElement.getAttribute('start-page'), 'guides/install.md'); + expect( + tree + .findAllElements('toc-element') + .where((node) => node.getAttribute('topic') == 'other.md'), + isEmpty, + ); + expect( + tree + .findAllElements('toc-element') + .any((node) => node.getAttribute('accepts-web-file-names') != null), + isFalse, + ); + expect(File(topicPath('other.md')).existsSync(), isTrue); }, ); + testWidgets('Home Page removal with no candidate explains how to unblock it', ( + tester, + ) async { + final tree = File(p.join(root.path, 'guide.tree')); + tree.writeAsStringSync( + '', + ); + final original = tree.readAsStringSync(); + await start(tester); + await openRemoval(tester); + expect(homeSelector(), findsNothing); + expect( + find.text( + 'Add or link another eligible topic to this instance before removing its Home Page.', + ), + findsOneWidget, + ); + expect(removeButton(tester).onPressed, isNull); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + expect(tree.readAsStringSync(), original); + }); + + testWidgets( + 'Review Usages without a Home Page choice resumes in the actionable dialog', + (tester) async { + await start(tester); + final tree = File(p.join(root.path, 'guide.tree')); + final original = tree.readAsStringSync(); + await openRemoval(tester); + await tester.tap(find.text('Review Usages')); + await tester.pumpAndSettle(); + expect(tree.readAsStringSync(), original); + await tester.runAsync(() => tester.tap(find.text('Do Refactor'))); + await _settle(tester, until: () => homeSelector().evaluate().isNotEmpty); + expect( + tester + .widget>( + homeSelector(), + ) + .value, + isNull, + ); + expect(removeButton(tester).onPressed, isNull); + await chooseHome(tester); + await tester.runAsync(() => tester.tap(find.text('Remove'))); + await keepOrphan(tester); + expect( + XmlDocument.parse( + tree.readAsStringSync(), + ).rootElement.getAttribute('start-page'), + 'guides/install.md', + ); + }, + ); + + testWidgets( + 'Review Usages keeps the explicit Home Page choice across changed TOC positions', + (tester) async { + await start(tester); + final tree = File(p.join(root.path, 'guide.tree')); + final original = tree.readAsStringSync(); + await openRemoval(tester); + await chooseHome(tester); + await tester.tap(find.text('Review Usages')); + await tester.pumpAndSettle(); + expect(tree.readAsStringSync(), original); + tree.writeAsStringSync( + original.replaceFirst( + '', + '', + ), + ); + await tester.runAsync(() => tester.tap(find.text('Do Refactor'))); + await keepOrphan(tester); + expect( + XmlDocument.parse( + tree.readAsStringSync(), + ).rootElement.getAttribute('start-page'), + 'guides/install.md', + ); + expect(File(topicPath('other.md')).existsSync(), isTrue); + }, + ); + + testWidgets( + 'Review Usages reopens the selector when the chosen replacement is no longer eligible', + (tester) async { + await start(tester); + final tree = File(p.join(root.path, 'guide.tree')); + await openRemoval(tester); + await chooseHome(tester); + await tester.tap(find.text('Review Usages')); + await tester.pumpAndSettle(); + tree.writeAsStringSync( + tree.readAsStringSync().replaceAll( + 'topic="guides/install.md"', + 'topic="elsewhere/install.md"', + ), + ); + final changed = tree.readAsStringSync(); + await tester.runAsync(() => tester.tap(find.text('Do Refactor'))); + await _settle(tester, until: () => homeSelector().evaluate().isNotEmpty); + final picker = tester + .widget>( + homeSelector(), + ); + expect(picker.value, isNull); + expect( + picker.options.single.value.topicReference, + 'elsewhere/install.md', + ); + expect(removeButton(tester).onPressed, isNull); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + expect(tree.readAsStringSync(), changed); + }, + ); + + testWidgets( + 'Home Page replacement and automatic usage consent remain independent', + (tester) async { + File( + topicPath('guides/install.md'), + ).writeAsStringSync('# guides/install.md\n\n[Other](../other.md)\n'); + await start(tester); + await openRemoval(tester); + expect( + tester.widget(automaticCheckbox()).onChanged, + isNotNull, + ); + await tester.tap(automaticCheckbox()); + await tester.pumpAndSettle(); + await chooseHome(tester); + expect(removeButton(tester).onPressed, isNull); + await tester.tap(automaticCheckbox()); + await tester.pumpAndSettle(); + expect(removeButton(tester).onPressed, isNotNull); + await tester.runAsync(() => tester.tap(find.text('Remove'))); + await keepOrphan(tester); + expect( + File(topicPath('guides/install.md')).readAsStringSync(), + isNot(contains('../other.md')), + ); + expect( + XmlDocument.parse( + File(p.join(root.path, 'guide.tree')).readAsStringSync(), + ).rootElement.getAttribute('start-page'), + 'guides/install.md', + ); + }, + ); + + testWidgets( + 'non-Home-Page removal has no Home Page selector and remains enabled', + (tester) async { + await start(tester); + final tree = File(p.join(root.path, 'guide.tree')); + final original = tree.readAsStringSync(); + await openRemoval(tester, row: '4'); + expect(homeSelector(), findsNothing); + expect(find.text('New Home Page'), findsNothing); + expect(removeButton(tester).onPressed, isNotNull); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + expect(tree.readAsStringSync(), original); + }, + ); + + testWidgets('Home Page choice is revalidated after saving a dirty buffer', ( + tester, + ) async { + await start(tester); + await tester.runAsync( + () => controller().openActiveFile(topicPath('guides/install.md')), + ); + await _settle(tester); + await openRemoval(tester); + await chooseHome(tester); + controller().updateActiveText( + '# guides/install.md\n\nSaved during removal.\n', + ); + await tester.pumpAndSettle(); + await tester.runAsync(() => tester.tap(find.text('Remove'))); + await _settle( + tester, + until: () => find.text('Unsaved changes').evaluate().isNotEmpty, + ); + await tester.runAsync( + () => tester.tap(find.widgetWithText(BusyMarkDialogButton, 'Save')), + ); + await _settle(tester, until: () => homeSelector().evaluate().isNotEmpty); + expect( + tester + .widget>( + homeSelector(), + ) + .value + ?.topicReference, + 'guides/install.md', + ); + expect(removeButton(tester).onPressed, isNotNull); + expect( + XmlDocument.parse( + File(p.join(root.path, 'guide.tree')).readAsStringSync(), + ).rootElement.getAttribute('start-page'), + 'other.md', + ); + await tester.runAsync(() => tester.tap(find.text('Remove'))); + await keepOrphan(tester); + expect( + XmlDocument.parse( + File(p.join(root.path, 'guide.tree')).readAsStringSync(), + ).rootElement.getAttribute('start-page'), + 'guides/install.md', + ); + expect( + File(topicPath('guides/install.md')).readAsStringSync(), + contains('Saved during removal.'), + ); + }); + testWidgets( 'New Topic surfaces expose local Markdown import only at root/sibling', (tester) async { @@ -472,8 +796,9 @@ Future _settle( WidgetTester tester, { bool Function()? until, String Function()? diagnostics, + Duration timeout = const Duration(seconds: 10), }) async { - final deadline = DateTime.now().add(const Duration(seconds: 10)); + final deadline = DateTime.now().add(timeout); for ( var i = 0; until == null ? i < 30 : DateTime.now().isBefore(deadline); diff --git a/test/src/writerside_toc_workspace_test.dart b/test/src/writerside_toc_workspace_test.dart index cd1bf787..9bdfc30f 100644 --- a/test/src/writerside_toc_workspace_test.dart +++ b/test/src/writerside_toc_workspace_test.dart @@ -166,6 +166,99 @@ void main() { }, ); + for (final afterPublication in [false, true]) { + test( + 'combined Home Page removal rechecks dirty buffers (published=$afterPublication)', + () async { + final root = await fixture(); + const service = WorkspaceService(); + final workspace = await service.openPath(root.path); + final tree = File(p.join(root.path, 'guide.tree')); + final originals = { + for (final file in root.listSync(recursive: true).whereType()) + file.path: file.readAsStringSync(), + }; + final home = workspace.writersideModule!.topicByReference('home.md')!; + final analysis = await service.analyzeWritersideTopicRemoval( + workspace, + topicPath: home.filePath, + mode: WritersideTopicRemovalMode.removeFromInstance, + treePath: tree.path, + nodePath: [0], + ); + final replacement = analysis.homePageReplacements.singleWhere( + (candidate) => candidate.topicReference == 'details.topic', + ); + var rejected = false; + await expectLater( + service.applyWritersideTopicRemoval( + workspace, + WritersideTopicRemovalRequest( + analysis: analysis, + homePageReplacement: replacement, + updateUsagesAutomatically: true, + ), + validateBeforeCommit: (paths) { + expect(paths, contains(tree.path)); + if (!afterPublication || + tree.readAsStringSync() != originals[tree.path]) { + rejected = true; + throw StateError('became dirty'); + } + }, + ), + throwsStateError, + ); + expect(rejected, isTrue); + for (final entry in originals.entries) { + expect(File(entry.key).readAsStringSync(), entry.value); + } + }, + ); + } + + test( + 'combined Home Page removal preserves other instances and unrelated attributes', + () async { + final root = await fixture(); + const service = WorkspaceService(); + final workspace = await service.openPath(root.path); + final tree = File(p.join(root.path, 'guide.tree')); + final api = File(p.join(root.path, 'api.tree')); + final apiBefore = api.readAsStringSync(); + final home = workspace.writersideModule!.topicByReference('home.md')!; + final source = File(home.filePath).readAsStringSync(); + final analysis = await service.analyzeWritersideTopicRemoval( + workspace, + topicPath: home.filePath, + mode: WritersideTopicRemovalMode.removeFromInstance, + treePath: tree.path, + nodePath: [0], + ); + await service.applyWritersideTopicRemoval( + workspace, + WritersideTopicRemovalRequest( + analysis: analysis, + homePageReplacement: analysis.homePageReplacements.singleWhere( + (candidate) => candidate.topicReference == 'details.topic', + ), + updateUsagesAutomatically: true, + ), + ); + final xml = XmlDocument.parse(tree.readAsStringSync()).rootElement; + expect(xml.getAttribute('start-page'), 'details.topic'); + expect(xml.getAttribute('name'), 'User Guide'); + expect( + xml + .findAllElements('toc-element') + .any((element) => element.getAttribute('topic') == 'home.md'), + isFalse, + ); + expect(api.readAsStringSync(), apiBefore); + expect(File(home.filePath).readAsStringSync(), source); + }, + ); + for (final scenario in [ ( name: 'empty instance', diff --git a/test/src/writerside_topic_removal_service_test.dart b/test/src/writerside_topic_removal_service_test.dart index 60b45462..90db0e84 100644 --- a/test/src/writerside_topic_removal_service_test.dart +++ b/test/src/writerside_topic_removal_service_test.dart @@ -14,6 +14,365 @@ import 'package:xml/xml.dart'; void main() { const service = WritersideTopicRemovalService(); + Future analyzeHome( + ({Directory root, WritersideModule module}) fixture, + ) => service.analyze( + module: fixture.module, + topicPath: _topic(fixture.module, 'doomed.md').filePath, + mode: WritersideTopicRemovalMode.removeFromInstance, + selectedTreePath: p.join(fixture.root.path, 'guide.tree'), + selectedNodePath: const [0], + ); + + for (final redirect in [false, true]) { + test( + 'explicit Home Page replacement promotes children (redirect=$redirect)', + () async { + final fixture = await _homeFixture(); + final analysis = await analyzeHome(fixture); + final replacement = analysis.homePageReplacements.singleWhere( + (candidate) => candidate.topicReference == 'guides/new.md', + ); + expect(replacement.label, 'New (guides/new.md)'); + expect( + analysis.blockingUsages.single.kind, + WritersideTopicUsageKind.startPage, + ); + expect(analysis.canUpdateUsagesAutomatically, isFalse); + expect(analysis.unresolvedBlockingUsages(replacement), isEmpty); + + final result = await service.apply( + WritersideTopicRemovalRequest( + analysis: analysis, + homePageReplacement: replacement, + redirectTarget: redirect + ? analysis.redirectTargets.singleWhere( + (candidate) => candidate.topicPath == replacement.topicPath, + ) + : null, + ), + ); + + final tree = _tree(fixture.root, 'guide.tree'); + expect(tree.rootElement.getAttribute('start-page'), 'guides/new.md'); + expect(tree.rootElement.getAttribute('web-path'), '/guide'); + expect(_topics(tree), ['guides/new.md', 'elsewhere/new.md']); + expect(result.promotedChildren, 1); + expect(result.orphaned, isTrue); + expect(result.deletedFile, isFalse); + expect(result.redirectAdded, redirect); + expect( + tree + .findAllElements('toc-element') + .first + .getAttribute('accepts-web-file-names'), + redirect ? 'doomed.html' : isNull, + ); + expect(File(analysis.topicPath).existsSync(), isTrue); + }, + ); + } + + test( + 'Home Page removal requires a replacement even with automatic consent', + () async { + final fixture = await _homeFixture(); + final analysis = await analyzeHome(fixture); + final originals = _sources(fixture.root); + for (final automatic in [false, true]) { + await expectLater( + service.apply( + WritersideTopicRemovalRequest( + analysis: analysis, + updateUsagesAutomatically: automatic, + ), + ), + throwsA(isA()), + ); + expect(_sources(fixture.root), originals); + } + }, + ); + + test( + 'invalid or wrong-instance Home Page replacements fail without writes', + () async { + final fixture = await _homeFixture( + otherTree: + '', + ); + final analysis = await analyzeHome(fixture); + final valid = analysis.homePageReplacements.first; + final originals = _sources(fixture.root); + final invalid = [ + WritersideHomePageReplacement( + topicPath: analysis.topicPath, + treePath: valid.treePath, + topicReference: 'doomed.md', + label: 'Doomed', + ), + WritersideHomePageReplacement( + topicPath: _topic(fixture.module, 'outside.md').filePath, + treePath: valid.treePath, + topicReference: 'outside.md', + label: 'Outside', + ), + WritersideHomePageReplacement( + topicPath: valid.topicPath, + treePath: p.join(fixture.root.path, 'other.tree'), + topicReference: valid.topicReference, + label: valid.label, + ), + WritersideHomePageReplacement( + topicPath: valid.topicPath, + treePath: valid.treePath, + topicReference: 'missing.md', + label: valid.label, + ), + ]; + for (final replacement in invalid) { + await expectLater( + service.apply( + WritersideTopicRemovalRequest( + analysis: analysis, + homePageReplacement: replacement, + updateUsagesAutomatically: true, + ), + ), + throwsA(isA()), + ); + expect(_sources(fixture.root), originals); + } + final safeDelete = await service.analyze( + module: fixture.module, + topicPath: analysis.topicPath, + mode: WritersideTopicRemovalMode.safeDeleteFile, + ); + expect(safeDelete.homePageReplacements, isEmpty); + await expectLater( + service.apply( + WritersideTopicRemovalRequest( + analysis: safeDelete, + homePageReplacement: valid, + updateUsagesAutomatically: true, + ), + ), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'writerside.topic-file.is-start-page', + ), + ), + ); + expect(_sources(fixture.root), originals); + }, + ); + + test( + 'Home Page candidates respect conditions, editable provenance and topic resolution', + () async { + final fixture = await _fixture( + trees: { + 'guide.tree': ''' + + + + + + + + + +''', + 'library.tree': + '', + }, + configuredTrees: ['guide.tree', 'library.tree'], + topics: { + for (final name in [ + 'doomed', + 'eligible', + 'inactive', + 'external', + 'ref', + 'included', + ]) + '$name.md': '# $name\n', + }, + ); + final analysis = await analyzeHome(fixture); + expect( + analysis.homePageReplacements.map( + (candidate) => candidate.topicReference, + ), + ['eligible.md'], + ); + }, + ); + + test( + 'explicit Home Page replacement still requires consent for links and includes', + () async { + final fixture = await _homeFixture( + referrer: + '# Referrer\n\n[Old](doomed.md)\n\n\n', + ); + final analysis = await analyzeHome(fixture); + final replacement = analysis.homePageReplacements.first; + final originals = _sources(fixture.root); + expect(analysis.canUpdateOtherUsagesAutomatically, isTrue); + expect(analysis.unresolvedBlockingUsages(replacement), hasLength(2)); + await expectLater( + service.apply( + WritersideTopicRemovalRequest( + analysis: analysis, + homePageReplacement: replacement, + ), + ), + throwsA(isA()), + ); + expect(_sources(fixture.root), originals); + final result = await service.apply( + WritersideTopicRemovalRequest( + analysis: analysis, + homePageReplacement: replacement, + updateUsagesAutomatically: true, + ), + ); + expect(result.orphaned, isTrue); + expect( + result.updatedUsageFiles, + contains(p.join(fixture.root.path, 'topics', 'referrer.md')), + ); + expect( + File( + p.join(fixture.root.path, 'topics', 'referrer.md'), + ).readAsStringSync(), + isNot(contains('doomed.md')), + ); + }, + ); + + test( + 'explicit Home Page replacement cannot resolve non-automatic blockers', + () async { + final fixture = await _homeFixture( + referrer: '# Referrer\n\nunfinished.\n', + ); + final analysis = await analyzeHome(fixture); + expect(analysis.canUpdateOtherUsagesAutomatically, isFalse); + final originals = _sources(fixture.root); + await expectLater( + service.apply( + WritersideTopicRemovalRequest( + analysis: analysis, + homePageReplacement: analysis.homePageReplacements.first, + updateUsagesAutomatically: true, + ), + ), + throwsA(isA()), + ); + expect(_sources(fixture.root), originals); + }, + ); + + for (final remaining in ['start-page="doomed.md"', '']) { + test( + 'Home Page reassignment preserves other instance usages ($remaining)', + () async { + final fixture = await _homeFixture( + otherTree: + '', + ); + final analysis = await analyzeHome(fixture); + final other = File(p.join(fixture.root.path, 'other.tree')); + final original = other.readAsStringSync(); + final result = await service.apply( + WritersideTopicRemovalRequest( + analysis: analysis, + homePageReplacement: analysis.homePageReplacements.first, + ), + ); + expect(result.orphaned, isFalse); + expect(other.readAsStringSync(), original); + expect(File(analysis.topicPath).existsSync(), isTrue); + }, + ); + } + + for (final change in ['tree', 'replacement', 'delete']) { + test( + 'stale Home Page removal fails closed after $change changes', + () async { + final fixture = await _homeFixture(); + final analysis = await analyzeHome(fixture); + final replacement = analysis.homePageReplacements.first; + if (change == 'tree') { + final tree = File(replacement.treePath); + tree.writeAsStringSync( + tree.readAsStringSync().replaceAll('/guide', '/changed'), + ); + } else if (change == 'delete') { + File(replacement.topicPath).deleteSync(); + } else { + File(replacement.topicPath).writeAsStringSync('# Changed\n'); + } + final originals = _sources(fixture.root); + await expectLater( + service.apply( + WritersideTopicRemovalRequest( + analysis: analysis, + homePageReplacement: replacement, + ), + ), + throwsA(isA()), + ); + expect(_sources(fixture.root), originals); + }, + ); + } + + test( + 'failed Home Page transaction rolls back reassignment and preserves concurrent edits', + () async { + final fixture = await _homeFixture( + referrer: '# Referrer\n\n[Old](doomed.md)\n', + ); + final analysis = await analyzeHome(fixture); + final replacement = analysis.homePageReplacements.first; + final originals = _sources(fixture.root); + final referrer = File(p.join(fixture.root.path, 'topics', 'referrer.md')); + var published = false; + await expectLater( + service.apply( + WritersideTopicRemovalRequest( + analysis: analysis, + homePageReplacement: replacement, + updateUsagesAutomatically: true, + ), + validateBeforeCommit: (_) { + if (File(replacement.treePath).readAsStringSync() == + originals[replacement.treePath]) { + return; + } + published = true; + referrer.writeAsStringSync('# Concurrent external edit\n'); + throw const BusyMarkException('test.concurrent-edit'); + }, + ), + throwsA(isA()), + ); + expect(published, isTrue); + expect(referrer.readAsStringSync(), '# Concurrent external edit\n'); + for (final entry in originals.entries) { + if (entry.key != referrer.path) { + expect(File(entry.key).readAsStringSync(), entry.value); + } + } + }, + ); + test( 'analysis finds every tree, topic link, include, and start page', () async { @@ -1950,6 +2309,37 @@ Other: Other guide. ); } +Map _sources(Directory root) => { + for (final file in root.listSync(recursive: true).whereType()) + file.path: file.readAsStringSync(), +}; + +Future<({Directory root, WritersideModule module})> _homeFixture({ + String? referrer, + String? otherTree, +}) => _fixture( + trees: { + 'guide.tree': + ''' + + + + + ${referrer == null ? '' : ''} + +''', + if (otherTree != null) 'other.tree': otherTree, + }, + configuredTrees: ['guide.tree', if (otherTree != null) 'other.tree'], + topics: { + 'doomed.md': '# Doomed\n\nReusable.\n', + 'guides/new.md': '# New\n', + 'elsewhere/new.md': '# New\n', + 'outside.md': '# Outside\n', + if (referrer != null) 'referrer.md': referrer, + }, +); + Future<({Directory root, String mainRoot, String sharedRoot})> _projectFixture({ required String mainTree, required String sharedTree, diff --git a/test/src/wysiwyg_clipboard_test.dart b/test/src/wysiwyg_clipboard_test.dart index fdeadab8..fb21f013 100644 --- a/test/src/wysiwyg_clipboard_test.dart +++ b/test/src/wysiwyg_clipboard_test.dart @@ -12,6 +12,7 @@ import 'package:busymark/src/clipboard/clipboard_history_controller.dart'; import 'package:busymark/src/clipboard/clipboard_history_panel.dart'; import 'package:busymark/src/clipboard/clipboard_insertion.dart'; import 'package:busymark/src/clipboard/clipboard_models.dart'; +import 'package:busymark/src/editor/clipboard_paste_resolver.dart'; import 'package:busymark/src/editor/wysiwyg/wysiwyg_clipboard_fragment.dart'; import 'package:busymark/src/editor/wysiwyg/wysiwyg_clipboard_html.dart'; import 'package:busymark/src/editor/wysiwyg/wysiwyg_document_controller.dart'; @@ -22,6 +23,7 @@ import 'package:busymark/src/editor/writerside_video_player_host.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/markdown/raw_html_adapter.dart'; import 'package:busymark/src/platform/native_menu_service.dart'; import 'package:busymark/src/platform/rich_clipboard_service.dart'; import 'package:busymark/src/visualization/visualization_providers.dart'; @@ -35,10 +37,29 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:html/parser.dart' as html; import 'package:path/path.dart' as p; +import '../support/widget_async.dart'; + const _source = '# Issues\n\n**When** selecting all, keep *formatting*.\n\n- [ ] First task\n- [x] Second task\n'; const _parser = MarkdownParser(); +final _htmlBoundaries = [ + for (final boundary in [ + (leading: '', trailing: ' '), + (leading: '', trailing: ' '), + (leading: ' ', trailing: ''), + (leading: '', trailing: ''), + ]) + for (final inside in [false, true]) + ( + leading: boundary.leading, + trailing: boundary.trailing, + html: inside + ? '${boundary.leading}hello world${boundary.trailing}' + : '${boundary.leading}hello world${boundary.trailing}', + ), +]; + WysiwygClipboardFragment _fragment( String source, { String path = '/source/topic.md', @@ -86,6 +107,129 @@ String _insert( return output; } +// Keep boundary spaces in the fixture independently of Markdown/HTML parsing. +WysiwygClipboardFragment _boundaryFragment(String leading, String trailing) => + WysiwygClipboardFragment( + mode: MarkdownMode.writersideMarkdown, + blocks: [ + BusyWysiwygStyledBlock( + kind: BusyBlockKind.paragraph, + text: '${leading}hello world$trailing', + ranges: [ + BusyInlineStyleRange( + start: leading.length, + end: leading.length + 11, + kind: BusyInlineKind.strong, + ), + ], + ), + ], + ); + +List _inlineTree(List inlines) => [ + for (final inline in inlines) + [inline.kind.name, inline.plainText, _inlineTree(inline.children)], +]; + +List<(int, int)> _strongRanges(BusyBlock block) => [ + for (final range in busyInlineStyleRanges(block.inlines)) + if (range.kind == BusyInlineKind.strong) (range.start, range.end), +]; + +// Compare coverage rather than wrapper nesting: a visual run may have several +// equivalent inline representations, but every character must retain its style. +void _expectInlineSemantics( + BusyBlock block, + String text, + List expected, +) { + expect(block.plainText, text); + final actual = busyInlineStyleRanges(block.inlines); + for (final kind in [ + BusyInlineKind.strong, + BusyInlineKind.emphasis, + BusyInlineKind.link, + ]) { + Map coverage(List ranges) => { + for (final range in ranges.where((range) => range.kind == kind)) + for (var offset = range.start; offset < range.end; offset++) + offset: kind == BusyInlineKind.link ? range.destination ?? '' : true, + }; + expect(coverage(actual), coverage(expected), reason: '$kind in $text'); + } +} + +void _expectSavedSemantics( + String markdown, + MarkdownMode mode, + String text, + List ranges, +) { + final parsed = _parser + .parse(filePath: '/reparsed.md', source: markdown, mode: mode) + .busyDocument; + // Markdown block parsing intentionally consumes trailing paragraph whitespace. + // The original editor text and saved bytes are checked separately. + final recovered = text.trimRight(); + _expectInlineSemantics(parsed.blocks.single, recovered, [ + for (final range in ranges) + BusyInlineStyleRange( + start: range.start.clamp(0, recovered.length), + end: range.end.clamp(0, recovered.length), + kind: range.kind, + destination: range.destination, + ), + ]); + expect(parsed.blocks.single.plainText, isNot(contains('**'))); +} + +void _expectBoundaryReplacement( + BusyBlock block, + String markdown, + int? caret, { + required String leading, + required String trailing, +}) { + expect( + { + 'text': block.plainText, + 'strong': _strongRanges(block), + 'markdown': markdown, + if (caret != null) 'caret': caret, + }, + { + 'text': '${leading}hello world$trailing', + 'strong': [(leading.length, leading.length + 11)], + 'markdown': '$leading**hello world**$trailing\n', + if (caret != null) 'caret': leading.length + 11 + trailing.length, + }, + reason: 'Live inline tree: ${_inlineTree(block.inlines)}', + ); + expect(_inlineTree(block.inlines), [ + if (leading.isNotEmpty) ['text', leading, []], + [ + 'strong', + 'hello world', + [ + ['text', 'hello world', []], + ], + ], + if (trailing.isNotEmpty) ['text', trailing, []], + ]); + for (final mode in [ + MarkdownMode.commonMark, + MarkdownMode.writersideMarkdown, + ]) { + _expectSavedSemantics(markdown, mode, block.plainText, [ + BusyInlineStyleRange( + start: leading.length, + end: leading.length + 11, + kind: BusyInlineKind.strong, + ), + ]); + } +} + void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -108,6 +252,408 @@ void main() { }, ); + for (final trailing in [' ', ' ']) { + test('structured replacement boundary with ${trailing.length} spaces', () { + final fragment = _boundaryFragment('', trailing); + final incoming = busyMarkWysiwygClipboardBlock(fragment.blocks.single); + expect(incoming.plainText, 'hello world$trailing'); + expect(_strongRanges(incoming), [(0, 11)]); + expect(incoming.inlines.last.kind, BusyInlineKind.text); + expect(incoming.inlines.last.text, trailing); + final document = _parser + .parse(filePath: '/destination.md', source: '**hello wold**\n') + .busyDocument; + final controller = BusyMarkWysiwygDocumentController(document: document); + addTearDown(controller.dispose); + final result = controller.insertStyledBlocksAtSelection( + blockId: document.blocks.single.id, + selectionStart: 0, + selectionEnd: 10, + blocks: fragment.blocks, + replacementScope: BusyWysiwygReplacementScope.fieldContent, + ); + _expectBoundaryReplacement( + controller.document.blocks.single, + controller.markdown, + result!.offset, + leading: '', + trailing: trailing, + ); + }); + } + + for (final kind in [BusyInlineKind.strong, BusyInlineKind.emphasis]) { + for (final mode in [ + MarkdownMode.commonMark, + MarkdownMode.writersideMarkdown, + ]) { + test( + 'paste boundary preserves a ${kind.name} run across link wrappers ($mode)', + () { + final ranges = [ + BusyInlineStyleRange(start: 0, end: 11, kind: kind), + const BusyInlineStyleRange( + start: 6, + end: 11, + kind: BusyInlineKind.link, + destination: 'guide.md', + attributes: {'title': 'Guide'}, + ), + ]; + final fragment = WysiwygClipboardFragment( + mode: mode, + blocks: [ + BusyWysiwygStyledBlock( + kind: BusyBlockKind.paragraph, + text: 'hello world ', + ranges: ranges, + ), + ], + ); + final incoming = fragment.documentBlocks.single; + _expectInlineSemantics(incoming, 'hello world ', ranges); + final document = _parser + .parse( + filePath: '/destination.md', + source: 'Target\n', + mode: mode, + ) + .busyDocument; + final controller = BusyMarkWysiwygDocumentController( + document: document, + ); + addTearDown(controller.dispose); + controller.insertStyledBlocksAtSelection( + blockId: document.blocks.single.id, + selectionStart: 0, + selectionEnd: 6, + blocks: fragment.blocks, + replacementScope: BusyWysiwygReplacementScope.fieldContent, + ); + final pasted = controller.document.blocks.single; + _expectInlineSemantics(pasted, 'hello world ', ranges); + final link = busyInlineStyleRanges( + pasted.inlines, + ).singleWhere((range) => range.kind == BusyInlineKind.link); + expect(link.destination, 'guide.md'); + expect(link.attributes, {'title': 'Guide'}); + _expectSavedSemantics( + controller.markdown, + mode, + 'hello world ', + ranges, + ); + final delimiter = kind == BusyInlineKind.strong ? '**' : '*'; + expect( + controller.markdown, + '$delimiter' + 'hello [world](guide.md "Guide")$delimiter \n', + ); + }, + ); + } + } + + test('HTML decoding preserves formatting-edge whitespace', () { + for (final value in _htmlBoundaries) { + final decoded = const WysiwygClipboardHtml().decode( + value.html, + mode: MarkdownMode.writersideMarkdown, + )!; + _expectInlineSemantics( + decoded.documentBlocks.single, + '${value.leading}hello world${value.trailing}', + [ + BusyInlineStyleRange( + start: value.leading.length, + end: value.leading.length + 11, + kind: BusyInlineKind.strong, + ), + ], + ); + expect( + decoded.markdown, + '${value.leading}**hello world**${value.trailing}\n', + ); + } + }); + + test( + 'HTML clipboard documents preserve body spaces and discard head content', + () { + final decoded = const WysiwygClipboardHtml().decode( + 'Other document' + ' hello world ', + mode: MarkdownMode.commonMark, + )!; + expect(decoded.blocks.single.text, ' hello world '); + expect(decoded.markdown, ' **hello world** \n'); + }, + ); + + for (final kind in [BusyInlineKind.strong, BusyInlineKind.emphasis]) { + for (final nested in [false, true]) { + test( + 'HTML formatting retains internal whitespace across a link ($kind, nested=$nested)', + () { + final tag = kind == BusyInlineKind.strong ? 'strong' : 'em'; + final otherTag = kind == BusyInlineKind.strong ? 'em' : 'strong'; + final label = nested ? '<$otherTag>world' : 'world'; + final fragment = const WysiwygClipboardHtml().decode( + '<$tag>hello $label ', + mode: MarkdownMode.commonMark, + )!; + final ranges = [ + BusyInlineStyleRange(start: 0, end: 11, kind: kind), + if (nested) + BusyInlineStyleRange( + start: 6, + end: 11, + kind: kind == BusyInlineKind.strong + ? BusyInlineKind.emphasis + : BusyInlineKind.strong, + ), + const BusyInlineStyleRange( + start: 6, + end: 11, + kind: BusyInlineKind.link, + destination: 'guide.md', + ), + ]; + _expectInlineSemantics( + fragment.documentBlocks.single, + 'hello world ', + ranges, + ); + final output = _insert(fragment); + for (final mode in [ + MarkdownMode.commonMark, + MarkdownMode.writersideMarkdown, + ]) { + _expectSavedSemantics(output, mode, 'hello world ', ranges); + } + expect(output.endsWith(' \n'), isTrue); + }, + ); + } + } + + test( + 'clipboard whitespace mode leaves ordinary raw HTML normalization intact', + () { + var id = 0; + for (final preserve in [false, true]) { + final blocks = RawHtmlAdapter(preserveInlineWhitespace: preserve) + .parseRawHtmlBlock( + '
hello world
', + () => 'html-${id++}', + )! + .blocks; + expect( + blocks.single.plainText, + preserve ? ' hello world ' : 'hello world', + ); + expect(_strongRanges(blocks.single), [ + (preserve ? 1 : 0, preserve ? 12 : 11), + ]); + } + }, + ); + + test('clipboard HTML layout whitespace adds no structural text', () { + final decoded = const WysiwygClipboardHtml().decode(''' +
+

+ Heading +

+

First

+

+ Second +

+
    +
  • Parent +
    • Child
    +
  • +
+ + + +
AB
OneTwo
+
+ ''', mode: MarkdownMode.writersideMarkdown)!; + final blocks = decoded.documentBlocks; + expect(blocks.map((block) => block.kind), [ + BusyBlockKind.heading, + BusyBlockKind.paragraph, + BusyBlockKind.paragraph, + BusyBlockKind.unorderedListItem, + BusyBlockKind.table, + ]); + expect(blocks.take(4).map((block) => block.plainText), [ + 'Heading', + 'First', + 'Second', + 'Parent', + ]); + expect(blocks[3].children.single.plainText, 'Child'); + expect( + blocks.last.children + .expand((row) => row.children) + .map((cell) => cell.plainText), + ['A', 'B', 'One', 'Two'], + ); + }); + + for (final mode in [ + MarkdownMode.commonMark, + MarkdownMode.writersideMarkdown, + ]) { + for (final value in [ + ( + name: 'adjacent strong segments', + text: 'hello world ', + ranges: const [ + BusyInlineStyleRange(start: 0, end: 6, kind: BusyInlineKind.strong), + BusyInlineStyleRange(start: 6, end: 11, kind: BusyInlineKind.strong), + ], + ), + ( + name: 'adjacent strong and emphasis', + text: 'helloworld ', + ranges: const [ + BusyInlineStyleRange(start: 0, end: 5, kind: BusyInlineKind.strong), + BusyInlineStyleRange( + start: 5, + end: 10, + kind: BusyInlineKind.emphasis, + ), + ], + ), + ( + name: 'strong beside a plain link', + text: 'hello world ', + ranges: const [ + BusyInlineStyleRange(start: 0, end: 5, kind: BusyInlineKind.strong), + BusyInlineStyleRange( + start: 6, + end: 11, + kind: BusyInlineKind.link, + destination: 'guide.md', + ), + ], + ), + ( + name: 'partially strong link label', + text: 'hello world ', + ranges: const [ + BusyInlineStyleRange(start: 6, end: 11, kind: BusyInlineKind.strong), + BusyInlineStyleRange( + start: 0, + end: 11, + kind: BusyInlineKind.link, + destination: 'guide.md', + ), + ], + ), + ( + name: 'strong run starting in a link', + text: 'hello world ', + ranges: const [ + BusyInlineStyleRange(start: 0, end: 11, kind: BusyInlineKind.strong), + BusyInlineStyleRange( + start: 0, + end: 5, + kind: BusyInlineKind.link, + destination: 'guide.md', + ), + ], + ), + ( + name: 'nested strong and emphasis across a link', + text: 'hello world ', + ranges: const [ + BusyInlineStyleRange(start: 0, end: 11, kind: BusyInlineKind.strong), + BusyInlineStyleRange( + start: 6, + end: 11, + kind: BusyInlineKind.emphasis, + ), + BusyInlineStyleRange( + start: 6, + end: 11, + kind: BusyInlineKind.link, + destination: 'guide.md', + ), + ], + ), + ( + name: 'nested emphasis and strong across a link', + text: 'hello world ', + ranges: const [ + BusyInlineStyleRange( + start: 0, + end: 11, + kind: BusyInlineKind.emphasis, + ), + BusyInlineStyleRange(start: 6, end: 11, kind: BusyInlineKind.strong), + BusyInlineStyleRange( + start: 6, + end: 11, + kind: BusyInlineKind.link, + destination: 'guide.md', + ), + ], + ), + ( + name: 'nested coincident formatting', + text: 'hello world ', + ranges: const [ + BusyInlineStyleRange(start: 0, end: 11, kind: BusyInlineKind.strong), + BusyInlineStyleRange( + start: 0, + end: 11, + kind: BusyInlineKind.emphasis, + ), + ], + ), + ]) { + test('paste serialization preserves ${value.name} ($mode)', () { + final document = _parser + .parse(filePath: '/destination.md', source: 'Target\n', mode: mode) + .busyDocument; + final controller = BusyMarkWysiwygDocumentController( + document: document, + ); + addTearDown(controller.dispose); + controller.insertStyledBlocksAtSelection( + blockId: document.blocks.single.id, + selectionStart: 0, + selectionEnd: 6, + replacementScope: BusyWysiwygReplacementScope.fieldContent, + blocks: [ + BusyWysiwygStyledBlock( + kind: BusyBlockKind.paragraph, + text: value.text, + ranges: value.ranges, + ), + ], + ); + _expectInlineSemantics( + controller.document.blocks.single, + value.text, + value.ranges, + ); + expect(controller.markdown.endsWith(' \n'), isTrue); + _expectSavedSemantics( + controller.markdown, + mode, + value.text, + value.ranges, + ); + }); + } + } + test('single rich paragraphs preserve list descendants through source', () { final incoming = _fragment('**X**\n').blocks; final cases = <({String source, BusyBlockKind kind})>[ @@ -477,19 +1023,11 @@ void main() { required String operation, String Function()? diagnostics, }) async { - // Image ingestion performs real filesystem I/O outside FakeAsync. Pump - // until the observable stage completes, retaining a bounded failure. - final deadline = DateTime.now().add(const Duration(seconds: 10)); - while (!complete() && DateTime.now().isBefore(deadline)) { - await tester.runAsync( - () => Future.delayed(const Duration(milliseconds: 25)), - ); - await tester.pump(); - } - expect( - complete(), - isTrue, - reason: '$operation did not complete. ${diagnostics?.call() ?? ''}', + await pumpUntil( + tester, + complete, + operation: operation, + diagnostics: diagnostics, ); } @@ -512,15 +1050,12 @@ void main() { bool hostToasts = false, bool providerScope = false, bool focusFirstField = true, + MarkdownMode mode = MarkdownMode.writersideMarkdown, WysiwygEditorSessionState Function(BusyDocument document)? initialSessionFor, }) async { final document = _parser - .parse( - filePath: filePath ?? '/$id.md', - source: source, - mode: MarkdownMode.writersideMarkdown, - ) + .parse(filePath: filePath ?? '/$id.md', source: source, mode: mode) .busyDocument; final app = MaterialApp( localizationsDelegates: AppLocalizations.localizationsDelegates, @@ -1561,6 +2096,464 @@ void main() { }, ); + for (final mode in [ + MarkdownMode.commonMark, + MarkdownMode.writersideMarkdown, + ]) { + for (final withPlainText in [false, true]) { + for (final value in _htmlBoundaries) { + testWidgets( + 'normal HTML paste retains boundary spaces ($mode, plain=$withPlainText, ${value.html})', + (tester) async { + const original = '**hello wold**\n'; + var saved = original; + BusyDocument? live; + systemData = { + 'html': value.html, + if (withPlainText) + 'text': '${value.leading}hello world${value.trailing}', + }; + await mount( + tester, + 'html-boundary', + original, + (source) => saved = source, + onDocumentChanged: (document) => live = document, + mode: mode, + ); + final service = tester + .widget( + find.byType(BusyMarkWysiwygEditor), + ) + .clipboardService!; + final snapshot = BusyMarkClipboardSnapshot.fromSystem( + await service.read(), + ); + expect(snapshot.richFragment, isNull); + expect( + snapshot.text, + withPlainText + ? '${value.leading}hello world${value.trailing}' + : null, + ); + final candidate = + const BusyMarkClipboardPasteResolver() + .resolve( + snapshot: snapshot, + mode: BusyMarkPasteMode.normal, + destination: BusyMarkPasteDestination.editor, + markdownMode: mode, + ) + .candidates + .first + as BusyMarkStructuredPasteCandidate; + expect(candidate.source, BusyMarkStructuredClipboardSource.html); + // Exercise the keyboard path even when the decoder is defective. + tester + .widget(find.byType(TextField).first) + .controller! + .selection = const TextSelection( + baseOffset: 0, + extentOffset: 10, + ); + await tester.pump(); + await key(tester, LogicalKeyboardKey.keyV); + await tester.pumpAndSettle(); + _expectBoundaryReplacement( + live!.blocks.single, + saved, + tester + .widget(find.byType(TextField).first) + .controller! + .selection + .extentOffset, + leading: value.leading, + trailing: value.trailing, + ); + expect( + candidate.fragment.blocks.single.text, + '${value.leading}hello world${value.trailing}', + ); + await key(tester, LogicalKeyboardKey.keyZ); + await tester.pumpAndSettle(); + expect(saved, original); + _expectInlineSemantics(live!.blocks.single, 'hello wold', [ + const BusyInlineStyleRange( + start: 0, + end: 10, + kind: BusyInlineKind.strong, + ), + ]); + await key(tester, LogicalKeyboardKey.keyZ, shift: true); + await tester.pumpAndSettle(); + _expectBoundaryReplacement( + live!.blocks.single, + saved, + null, + leading: value.leading, + trailing: value.trailing, + ); + }, + ); + } + } + } + + for (final mode in [ + MarkdownMode.commonMark, + MarkdownMode.writersideMarkdown, + ]) { + for (final kind in [BusyInlineKind.strong, BusyInlineKind.emphasis]) { + for (final useHtml in [false, true]) { + testWidgets( + 'normal ${useHtml ? 'HTML' : 'structured'} paste saves linked ${kind.name} and restores history ($mode)', + (tester) async { + var saved = 'Target\n'; + BusyDocument? live; + final ranges = [ + BusyInlineStyleRange(start: 0, end: 11, kind: kind), + const BusyInlineStyleRange( + start: 6, + end: 11, + kind: BusyInlineKind.link, + destination: 'guide.md', + attributes: {'title': 'Guide'}, + ), + ]; + final fragment = WysiwygClipboardFragment( + mode: mode, + blocks: [ + BusyWysiwygStyledBlock( + kind: BusyBlockKind.paragraph, + text: 'hello world ', + ranges: ranges, + ), + ], + ); + await mount( + tester, + 'linked-paste', + saved, + (source) => saved = source, + onDocumentChanged: (document) => live = document, + mode: mode, + ); + final service = tester + .widget( + find.byType(BusyMarkWysiwygEditor), + ) + .clipboardService!; + final tag = kind == BusyInlineKind.strong ? 'strong' : 'em'; + expect( + await service.write( + RichClipboardData( + text: 'hello world ', + html: useHtml + ? '<$tag>hello world ' + : null, + richFragment: useHtml ? null : fragment.encode(), + ), + ), + isTrue, + ); + tester + .widget(find.byType(TextField).first) + .controller! + .selection = const TextSelection( + baseOffset: 0, + extentOffset: 6, + ); + await tester.pump(); + await key(tester, LogicalKeyboardKey.keyV); + await tester.pumpAndSettle(); + final delimiter = kind == BusyInlineKind.strong ? '**' : '*'; + final expected = + '${delimiter}hello [world](guide.md "Guide")$delimiter \n'; + void check() { + expect(saved, expected); + _expectInlineSemantics( + live!.blocks.single, + 'hello world ', + ranges, + ); + _expectSavedSemantics(saved, mode, 'hello world ', ranges); + } + + check(); + expect( + tester + .widget(find.byType(TextField).first) + .controller! + .selection, + const TextSelection.collapsed(offset: 12), + ); + await key(tester, LogicalKeyboardKey.keyZ); + await tester.pumpAndSettle(); + expect(saved, 'Target\n'); + _expectInlineSemantics(live!.blocks.single, 'Target', []); + await key(tester, LogicalKeyboardKey.keyZ, shift: true); + await tester.pumpAndSettle(); + check(); + await mount(tester, 'linked-reopened', saved, (_) {}, mode: mode); + expect( + tester + .widget(find.byType(TextField).first) + .controller! + .text, + 'hello world', + ); + final reopened = _parser + .parse( + filePath: '/linked-reopened.md', + source: saved, + mode: mode, + ) + .busyDocument; + _expectInlineSemantics( + reopened.blocks.single, + 'hello world', + ranges, + ); + }, + ); + } + } + } + + for (final plain in [false, true]) { + for (final boundary in [ + (leading: '', trailing: ' '), + (leading: '', trailing: ' '), + (leading: ' ', trailing: ''), + (leading: '', trailing: ''), + ]) { + testWidgets('${plain ? 'plain' : 'structured'} paste boundary ' + '${boundary.leading.length}/${boundary.trailing.length}', ( + tester, + ) async { + const original = '**hello wold**\n'; + var saved = original; + BusyDocument? live; + await mount( + tester, + 'paste-boundary', + original, + (value) => saved = value, + onDocumentChanged: (value) => live = value, + ); + final fragment = _boundaryFragment( + boundary.leading, + boundary.trailing, + ); + final service = tester + .widget(find.byType(BusyMarkWysiwygEditor)) + .clipboardService!; + expect( + await service.write( + RichClipboardData( + text: fragment.blocks.single.text, + richFragment: fragment.encode(), + ), + ), + isTrue, + ); + final incoming = await service.read(); + expect(incoming.text, fragment.blocks.single.text); + expect( + _strongRanges( + WysiwygClipboardFragment.decode( + incoming.richFragment!, + )!.documentBlocks.single, + ), + [(boundary.leading.length, boundary.leading.length + 11)], + ); + final field = tester.widget(find.byType(TextField).first); + expect(field.controller!.text, 'hello wold'); + field.controller!.selection = const TextSelection( + baseOffset: 0, + extentOffset: 10, + ); + await tester.pump(); + await key(tester, LogicalKeyboardKey.keyV, shift: plain); + await tester.pumpAndSettle(); + void check({bool checkCaret = true}) { + final current = tester + .widget(find.byType(TextField).first) + .controller!; + expect(current.text, fragment.blocks.single.text); + expect(current.selection.isCollapsed, isTrue); + _expectBoundaryReplacement( + live!.blocks.single, + saved, + checkCaret ? current.selection.extentOffset : null, + leading: boundary.leading, + trailing: boundary.trailing, + ); + } + + check(); + await key(tester, LogicalKeyboardKey.keyZ); + await tester.pumpAndSettle(); + expect(saved, original); + expect(live!.blocks.single.plainText, 'hello wold'); + expect(_strongRanges(live!.blocks.single), [(0, 10)]); + await key(tester, LogicalKeyboardKey.keyZ, shift: true); + await tester.pumpAndSettle(); + check(checkCaret: false); + }); + } + } + + for (final plain in [false, true]) { + for (final value in [ + ( + name: 'whitespace only', + source: '**hello wold**\n', + start: 0, + end: 10, + text: ' ', + ranges: [], + expected: ' \n', + ), + ( + name: 'internal spaces', + source: '**Target**\n', + start: 2, + end: 4, + text: ' ', + ranges: [], + expected: '**Ta et**\n', + ), + ( + name: 'emphasis', + source: '*hello wold*\n', + start: 0, + end: 10, + text: 'hello world ', + ranges: [ + const BusyInlineStyleRange( + start: 0, + end: 11, + kind: BusyInlineKind.emphasis, + ), + ], + expected: '*hello world* \n', + ), + ( + name: 'nested emphasis', + source: '**hello *wold***\n', + start: 0, + end: 10, + text: 'hello world ', + ranges: [ + const BusyInlineStyleRange( + start: 0, + end: 11, + kind: BusyInlineKind.strong, + ), + const BusyInlineStyleRange( + start: 6, + end: 11, + kind: BusyInlineKind.emphasis, + ), + ], + expected: '**hello *world*** \n', + ), + ]) { + testWidgets( + '${plain ? 'plain' : 'structured'} paste boundary preserves ${value.name}', + (tester) async { + var saved = value.source; + BusyDocument? live; + await mount( + tester, + 'paste-format-boundary', + saved, + (source) => saved = source, + onDocumentChanged: (document) => live = document, + ); + final fragment = WysiwygClipboardFragment( + mode: MarkdownMode.writersideMarkdown, + blocks: [ + BusyWysiwygStyledBlock( + kind: BusyBlockKind.paragraph, + text: value.text, + ranges: value.ranges, + ), + ], + ); + final service = tester + .widget( + find.byType(BusyMarkWysiwygEditor), + ) + .clipboardService!; + expect( + await service.write( + RichClipboardData( + text: value.text, + richFragment: fragment.encode(), + ), + ), + isTrue, + ); + tester + .widget(find.byType(TextField).first) + .controller! + .selection = TextSelection( + baseOffset: value.start, + extentOffset: value.end, + ); + await tester.pump(); + await key(tester, LogicalKeyboardKey.keyV, shift: plain); + await tester.pumpAndSettle(); + void check({bool checkCaret = true}) { + expect( + saved, + value.expected, + reason: + 'Live inline tree: ${_inlineTree(live!.blocks.single.inlines)}', + ); + final field = tester + .widget(find.byType(TextField).first) + .controller!; + expect(field.text, live!.blocks.single.plainText); + if (checkCaret) { + expect( + field.selection, + TextSelection.collapsed( + offset: value.start + value.text.length, + ), + ); + } + if (value.text == ' ') { + expect(field.text, ' '); + expect(_inlineTree(live!.blocks.single.inlines), [ + ['text', ' ', []], + ]); + } else { + final reparsed = _parser + .parse(filePath: '/reparsed.md', source: saved) + .busyDocument + .blocks + .single; + expect(_inlineTree(live!.blocks.single.inlines), [ + ..._inlineTree(reparsed.inlines), + if (value.expected.endsWith(' \n')) ['text', ' ', []], + ]); + } + } + + check(); + await key(tester, LogicalKeyboardKey.keyZ); + expect(saved, value.source); + await key(tester, LogicalKeyboardKey.keyZ, shift: true); + await tester.pumpAndSettle(); + check(checkCaret: false); + }, + ); + } + } + testWidgets('plain paste persists intentional whitespace through history', ( tester, ) async { @@ -2943,6 +3936,25 @@ void main() { expect(find.byKey(BusyMarkImageDialogKeys.choose), findsOneWidget); } + Future chooseImage(File file, int publicationCount) async { + final commitsBefore = committed.length; + picks.add(file.path); + await tester.tap(find.byKey(BusyMarkImageDialogKeys.choose)); + await waitForCount(published, publicationCount); + final sourceField = find.descendant( + of: find.byKey(BusyMarkImageDialogKeys.source), + matching: find.byType(TextField), + ); + await waitForImageIo( + tester, + () => + tester.widget(sourceField).controller!.text == + published.last.markdownPath, + operation: 'selected image reflected in dialog', + ); + expect(committed, hasLength(commitsBefore)); + } + systemData = {'text': files[0].path}; var result = 'Target\n'; await mount( @@ -2957,9 +3969,7 @@ void main() { await key(tester, LogicalKeyboardKey.keyV); await waitForCount(published, 1); await waitForDialog(); - picks.add(files[1].path); - await tester.tap(find.byKey(BusyMarkImageDialogKeys.choose)); - await waitForCount(published, 2); + await chooseImage(files[1], 2); await tester.tap(find.byKey(BusyMarkImageDialogKeys.cancel)); await tester.pumpAndSettle(); await waitForCount(rolledBack, 2); @@ -2981,11 +3991,8 @@ void main() { await key(tester, LogicalKeyboardKey.keyV); await waitForCount(published, 3); await waitForDialog(); - picks.addAll([files[1].path, files[2].path]); - await tester.tap(find.byKey(BusyMarkImageDialogKeys.choose)); - await waitForCount(published, 4); - await tester.tap(find.byKey(BusyMarkImageDialogKeys.choose)); - await waitForCount(published, 5); + await chooseImage(files[1], 4); + await chooseImage(files[2], 5); await tester.tap(find.byKey(BusyMarkImageDialogKeys.submit)); await tester.pumpAndSettle(); await waitForCount(committed, 1); @@ -3017,9 +4024,7 @@ void main() { await key(tester, LogicalKeyboardKey.keyV); await waitForCount(published, 6); await waitForDialog(); - picks.add(files[1].path); - await tester.tap(find.byKey(BusyMarkImageDialogKeys.choose)); - await waitForCount(published, 7); + await chooseImage(files[1], 7); final destination = tester.widget( find.widgetWithText(TextField, 'Target'), ); @@ -3083,6 +4088,9 @@ void main() { assetIngestionService: ingestion, hostToasts: true, ); + final l10n = AppLocalizations.of( + tester.element(find.byType(BusyMarkWysiwygEditor)), + ); await key(tester, LogicalKeyboardKey.keyV); await waitForImageIo( tester, @@ -3093,8 +4101,8 @@ void main() { await tester.tap(find.byKey(BusyMarkImageDialogKeys.submit)); await waitForImageIo( tester, - () => commitAttempts > 0, - operation: 'failed image commit attempt', + () => find.text(l10n.clipboardUnavailable).evaluate().isNotEmpty, + operation: 'failed image commit reported to the user', ); expect(commitAttempts, 1); @@ -3110,7 +4118,7 @@ void main() { await tester.runAsync( () => File(published.single.absolutePath).readAsBytes(), ), - isNotEmpty, + await tester.runAsync(source.readAsBytes), ); final pending = await tester.runAsync( () => Directory('${root.path}/images/.busymark-asset-transactions') @@ -3122,6 +4130,15 @@ void main() { .toList(), ); expect(pending, hasLength(1)); + final recordSource = await tester.runAsync( + () => File(pending!.single.path).readAsString(), + ); + final record = jsonDecode(recordSource!) as Map; + expect(record['publicationToken'], published.single.publicationId); + expect( + record['finalFilename'], + p.basename(published.single.absolutePath), + ); }, ); diff --git a/test/support/widget_async.dart b/test/support/widget_async.dart new file mode 100644 index 00000000..7ec4c0cf --- /dev/null +++ b/test/support/widget_async.dart @@ -0,0 +1,29 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; + +/// Drives both Flutter's fake clock and real I/O until an observable result. +Future pumpUntil( + WidgetTester tester, + bool Function() complete, { + required String operation, + Duration timeout = const Duration(seconds: 10), + Duration frameStep = Duration.zero, + String Function()? diagnostics, +}) async { + final elapsed = Stopwatch()..start(); + while (!complete()) { + if (elapsed.elapsed >= timeout) { + throw TimeoutException( + '$operation did not complete. ${diagnostics?.call() ?? ''}', + ); + } + await tester.pump(frameStep); + if (!complete()) { + // Let file/isolate events run, then flush their FakeAsync continuations + // on the next pump. Sleeping here adds latency to every I/O boundary. + await tester.runAsync(() => Future(() {})); + } + } + await tester.pump(); +} diff --git a/tools/nextcloud_login_live_acceptance.py b/tools/nextcloud_login_live_acceptance.py index 29e2662d..f207be0f 100644 --- a/tools/nextcloud_login_live_acceptance.py +++ b/tools/nextcloud_login_live_acceptance.py @@ -55,6 +55,9 @@ def main(): browser = playwright.chromium.launch( executable_path=os.getenv("BUSYMARK_CHROME_PATH", "/usr/bin/google-chrome"), headless=True, + # Disposable acceptance only: trust the explicitly supplied leaf key. + args=(["--ignore-certificate-errors-spki-list=" + os.environ["BUSYMARK_TEST_TLS_SPKI"]] + if "BUSYMARK_TEST_TLS_SPKI" in os.environ else []), ) try: page = browser.new_page() diff --git a/tools/nextcloud_notes_live_acceptance.dart b/tools/nextcloud_notes_live_acceptance.dart index d0f6bc22..748755da 100644 --- a/tools/nextcloud_notes_live_acceptance.dart +++ b/tools/nextcloud_notes_live_acceptance.dart @@ -17,6 +17,12 @@ class _Connection extends http.BaseClient { final http.Client _client; bool offline = false; bool loseNextCreateResponse = false; + bool discardNextCreate = false; + Map? discardedCreation; + int attachmentUploads = 0; + int noteUpdates = 0; + final noteUpdatesById = {}; + final attachmentUploadsById = {}; int noteCreates = 0; int chunkRequests = 0; @override @@ -26,6 +32,24 @@ class _Connection extends http.BaseClient { final creation = request.method == 'POST' && request.url.path.endsWith('/v1/notes'); if (creation) noteCreates++; + if (request.method == 'POST' && request.url.path.contains('/attachment/')) { + attachmentUploads++; + final id = int.parse(request.url.pathSegments.last); + attachmentUploadsById.update(id, (v) => v + 1, ifAbsent: () => 1); + } + if (request.method == 'PUT' && request.url.path.contains('/notes/')) { + noteUpdates++; + final id = int.parse(request.url.pathSegments.last); + noteUpdatesById.update(id, (v) => v + 1, ifAbsent: () => 1); + } + if (creation && discardNextCreate) { + discardNextCreate = false; + discardedCreation = + jsonDecode((request as http.Request).body) as Map; + throw http.ClientException( + 'Intentional nondelivery of creation request.', + ); + } final response = await _client.send(request); if (creation && loseNextCreateResponse) { loseNextCreateResponse = false; @@ -334,6 +358,35 @@ Future main(List arguments) async { uncertain.localId, content: '${pending.content}\nNewer local edit', ); + for (var i = 0; i < 3; i++) { + await repository.synchronize(account.id); + } + final unresolved = repository.noteById(uncertain.localId)!; + _require( + unresolved.serverId == null && + unresolved.syncState == NoteSyncState.creationUncertain, + 'Discovery adopted the uncertain draft without confirmation.', + ); + final candidate = repository + .uncertainCreationCandidates(uncertain.localId) + .single + .base!; + createdIds.add(candidate.id); + final unchangedCandidate = await api(account).get(candidate.id); + _require( + jsonEncode(unchangedCandidate.toJson()) == + jsonEncode(candidate.toJson()) && + (connection.attachmentUploadsById[candidate.id] ?? 0) == 0 && + (connection.noteUpdatesById[candidate.id] ?? 0) == 0 && + connection.noteCreates == beforeCreates + 1, + 'Discovery mutated the candidate or published an attachment.', + ); + await repository.resolveConflict( + uncertain.localId, + NoteConflictResolution.useServerNote, + creationCandidateServerId: candidate.id, + creationReview: repository.creationReview(uncertain.localId, candidate), + ); await repository.synchronize(account.id); final adopted = repository.noteById(uncertain.localId)!; _require( @@ -356,8 +409,76 @@ Future main(List arguments) async { adopted.category != pending.category, 'Reconciliation failed to adopt sanitized title/category and published content.', ); - checks['uncertainCreateStagedAttachmentSanitizationRestartNoDuplicate'] = - true; + final adoptedAttachment = (await repository.attachments( + uncertain.localId, + )).single; + final received = await (await api(account).fetchAttachment( + adopted.serverId!, + adoptedAttachment.remotePath!, + destination: File('${directory.path}/adopted-attachment'), + )).readAsBytes(); + final stagedBytes = await repository.store.attachmentBytes( + adoptedAttachment.id, + ); + _require( + base64Encode(received) == base64Encode(stagedBytes!), + 'Adopted attachment bytes differ.', + ); + await repository.dispose(); + repository = await reopen(); + final durableBinding = repository.noteById(uncertain.localId)!; + _require( + durableBinding.serverId == adopted.serverId && + !durableBinding.hasPendingChanges && + durableBinding.creationAttempt == null, + 'Adopted binding did not survive restart.', + ); + checks['uncertainCreateExplicitAdoptionAttachmentRestart'] = true; + + connection.discardNextCreate = true; + final counterexample = await repository.create( + account.id, + title: 'Independent identical candidate', + content: '# Independent identical content', + ); + await repository.synchronize(account.id); + final wire = connection.discardedCreation!; + final actor = api(account); + final independent = await actor.create( + NextcloudNote( + localId: 'independent', + accountId: account.id, + title: wire['title'] as String, + content: wire['content'] as String, + category: wire['category'] as String, + favorite: wire['favorite'] as bool, + creationAttempt: NotesCreationAttempt( + id: 'actor', + revision: 1, + localContent: wire['content'] as String, + wireBody: jsonEncode(wire), + knownServerIds: {}, + ), + ), + ); + createdIds.add(independent.id); + final beforeIndependentUpdates = + connection.noteUpdatesById[independent.id] ?? 0; + for (var i = 0; i < 3; i++) { + await repository.synchronize(account.id); + } + _require( + repository.noteById(counterexample.localId)!.serverId == null && + repository + .uncertainCreationCandidates(counterexample.localId) + .any((n) => n.serverId == independent.id) && + (connection.noteUpdatesById[independent.id] ?? 0) == + beforeIndependentUpdates && + jsonEncode((await actor.get(independent.id)).toJson()) == + jsonEncode(independent.toJson()), + 'An independently created identical note was adopted or mutated.', + ); + checks['independentIdenticalCandidateRequiresConfirmation'] = true; final chunked = await api(account).list(chunkSize: 1); _require(